feat: add bulk triage view component and related stories
- Exported BulkTriageViewComponent and its related types from findings module. - Created a new accessibility test suite for score components using axe-core. - Introduced design tokens for score components to standardize styling. - Enhanced score breakdown popover for mobile responsiveness with drag handle. - Added date range selector functionality to score history chart component. - Implemented unit tests for date range selector in score history chart. - Created Storybook stories for bulk triage view and score history chart with date range selector.
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FederationE2ETests.cs
|
||||
// Sprint: SPRINT_8200_0014_0003_CONCEL_bundle_import_merge
|
||||
// Tasks: IMPORT-8200-024, IMPORT-8200-029, IMPORT-8200-033
|
||||
// Description: End-to-end tests for federation scenarios
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Federation.Compression;
|
||||
using StellaOps.Concelier.Federation.Import;
|
||||
using StellaOps.Concelier.Federation.Models;
|
||||
using StellaOps.Concelier.Federation.Serialization;
|
||||
using StellaOps.Concelier.Federation.Signing;
|
||||
using System.Formats.Tar;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Federation.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for federation scenarios.
|
||||
/// </summary>
|
||||
public sealed class FederationE2ETests : IDisposable
|
||||
{
|
||||
private readonly List<Stream> _disposableStreams = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var stream in _disposableStreams)
|
||||
{
|
||||
stream.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#region Export to Import Round-Trip Tests (Task 24)
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_ExportBundle_ImportVerifiesState()
|
||||
{
|
||||
// This test simulates: export from Site A -> import to Site B -> verify state
|
||||
// Arrange - Site A exports a bundle
|
||||
var siteAManifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "site-a",
|
||||
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
|
||||
SinceCursor = null,
|
||||
ExportedAt = DateTimeOffset.Parse("2025-01-15T10:00:00Z"),
|
||||
BundleHash = "sha256:roundtrip-test",
|
||||
Counts = new BundleCounts { Canonicals = 3, Edges = 3, Deletions = 1 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(siteAManifest, 3, 3, 1);
|
||||
|
||||
// Act - Site B reads and parses the bundle
|
||||
using var reader = await BundleReader.ReadAsync(bundleStream);
|
||||
|
||||
// Assert - Manifest parsed correctly
|
||||
reader.Manifest.SiteId.Should().Be("site-a");
|
||||
reader.Manifest.Counts.Canonicals.Should().Be(3);
|
||||
|
||||
// Assert - Content streams correctly
|
||||
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
|
||||
var edges = await reader.StreamEdgesAsync().ToListAsync();
|
||||
var deletions = await reader.StreamDeletionsAsync().ToListAsync();
|
||||
|
||||
canonicals.Should().HaveCount(3);
|
||||
edges.Should().HaveCount(3);
|
||||
deletions.Should().HaveCount(1);
|
||||
|
||||
// Verify canonical data integrity
|
||||
canonicals.All(c => c.Id != Guid.Empty).Should().BeTrue();
|
||||
canonicals.All(c => c.MergeHash.StartsWith("sha256:")).Should().BeTrue();
|
||||
canonicals.All(c => c.Status == "active").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_DeltaBundle_OnlyIncludesChanges()
|
||||
{
|
||||
// Arrange - Delta bundle with since_cursor
|
||||
var deltaManifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "site-a",
|
||||
ExportCursor = "2025-01-15T12:00:00.000Z#0050",
|
||||
SinceCursor = "2025-01-15T10:00:00.000Z#0001", // Delta since previous cursor
|
||||
ExportedAt = DateTimeOffset.Parse("2025-01-15T12:00:00Z"),
|
||||
BundleHash = "sha256:delta-bundle",
|
||||
Counts = new BundleCounts { Canonicals = 5, Edges = 2, Deletions = 0 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(deltaManifest, 5, 2, 0);
|
||||
|
||||
// Act
|
||||
using var reader = await BundleReader.ReadAsync(bundleStream);
|
||||
|
||||
// Assert - Delta bundle has since_cursor
|
||||
reader.Manifest.SinceCursor.Should().Be("2025-01-15T10:00:00.000Z#0001");
|
||||
reader.Manifest.ExportCursor.Should().Be("2025-01-15T12:00:00.000Z#0050");
|
||||
|
||||
// Delta only has 5 canonicals (changes since cursor)
|
||||
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
|
||||
canonicals.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_VerifyBundle_PassesValidation()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "verified-site",
|
||||
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:verified",
|
||||
Counts = new BundleCounts { Canonicals = 2 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(manifest, 2, 0, 0);
|
||||
using var reader = await BundleReader.ReadAsync(bundleStream);
|
||||
|
||||
var signerMock = new Mock<IBundleSigner>();
|
||||
signerMock
|
||||
.Setup(x => x.VerifyBundleAsync(It.IsAny<string>(), It.IsAny<BundleSignature>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleVerificationResult { IsValid = true, SignerIdentity = "trusted-key" });
|
||||
|
||||
var options = Options.Create(new FederationImportOptions());
|
||||
var verifier = new BundleVerifier(signerMock.Object, options, NullLogger<BundleVerifier>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAsync(reader, skipSignature: true);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Manifest.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Air-Gap Workflow Tests (Task 29)
|
||||
|
||||
[Fact]
|
||||
public async Task AirGap_ExportToFile_ImportFromFile_Succeeds()
|
||||
{
|
||||
// This simulates: export to file -> transfer (air-gap) -> import from file
|
||||
// Arrange - Create bundle
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "airgap-source",
|
||||
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:airgap-bundle",
|
||||
Counts = new BundleCounts { Canonicals = 10, Edges = 15, Deletions = 2 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(manifest, 10, 15, 2);
|
||||
|
||||
// Simulate writing to file (in memory for test)
|
||||
var fileBuffer = new MemoryStream();
|
||||
bundleStream.Position = 0;
|
||||
await bundleStream.CopyToAsync(fileBuffer);
|
||||
fileBuffer.Position = 0;
|
||||
|
||||
// Act - "Transfer" and read from file
|
||||
using var reader = await BundleReader.ReadAsync(fileBuffer);
|
||||
|
||||
// Assert - All data survives air-gap transfer
|
||||
reader.Manifest.SiteId.Should().Be("airgap-source");
|
||||
|
||||
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
|
||||
var edges = await reader.StreamEdgesAsync().ToListAsync();
|
||||
var deletions = await reader.StreamDeletionsAsync().ToListAsync();
|
||||
|
||||
canonicals.Should().HaveCount(10);
|
||||
edges.Should().HaveCount(15);
|
||||
deletions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AirGap_LargeBundle_StreamsEfficiently()
|
||||
{
|
||||
// Arrange - Large bundle
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "large-site",
|
||||
ExportCursor = "2025-01-15T10:00:00.000Z#0100",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:large-bundle",
|
||||
Counts = new BundleCounts { Canonicals = 100, Edges = 200, Deletions = 10 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(manifest, 100, 200, 10);
|
||||
|
||||
// Act - Stream and count items
|
||||
using var reader = await BundleReader.ReadAsync(bundleStream);
|
||||
|
||||
var canonicalCount = 0;
|
||||
await foreach (var _ in reader.StreamCanonicalsAsync())
|
||||
{
|
||||
canonicalCount++;
|
||||
}
|
||||
|
||||
var edgeCount = 0;
|
||||
await foreach (var _ in reader.StreamEdgesAsync())
|
||||
{
|
||||
edgeCount++;
|
||||
}
|
||||
|
||||
// Assert - All items streamed
|
||||
canonicalCount.Should().Be(100);
|
||||
edgeCount.Should().Be(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AirGap_BundleWithAllEntryTypes_HasAllFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "complete-site",
|
||||
ExportCursor = "cursor",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:complete",
|
||||
Counts = new BundleCounts { Canonicals = 1, Edges = 1, Deletions = 1 }
|
||||
};
|
||||
|
||||
var bundleStream = await CreateTestBundleAsync(manifest, 1, 1, 1);
|
||||
|
||||
// Act
|
||||
using var reader = await BundleReader.ReadAsync(bundleStream);
|
||||
var entries = await reader.GetEntryNamesAsync();
|
||||
|
||||
// Assert - All expected files present
|
||||
entries.Should().Contain("MANIFEST.json");
|
||||
entries.Should().Contain("canonicals.ndjson");
|
||||
entries.Should().Contain("edges.ndjson");
|
||||
entries.Should().Contain("deletions.ndjson");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Site Federation Tests (Task 33)
|
||||
|
||||
[Fact]
|
||||
public async Task MultiSite_DifferentSiteIds_ParsedCorrectly()
|
||||
{
|
||||
// Arrange - Bundles from different sites
|
||||
var siteAManifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "us-west-1",
|
||||
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:site-a",
|
||||
Counts = new BundleCounts { Canonicals = 5 }
|
||||
};
|
||||
|
||||
var siteBManifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "eu-central-1",
|
||||
ExportCursor = "2025-01-15T11:00:00.000Z#0002",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:site-b",
|
||||
Counts = new BundleCounts { Canonicals = 8 }
|
||||
};
|
||||
|
||||
var bundleA = await CreateTestBundleAsync(siteAManifest, 5, 0, 0);
|
||||
var bundleB = await CreateTestBundleAsync(siteBManifest, 8, 0, 0);
|
||||
|
||||
// Act
|
||||
using var readerA = await BundleReader.ReadAsync(bundleA);
|
||||
using var readerB = await BundleReader.ReadAsync(bundleB);
|
||||
|
||||
// Assert - Each site has distinct data
|
||||
readerA.Manifest.SiteId.Should().Be("us-west-1");
|
||||
readerB.Manifest.SiteId.Should().Be("eu-central-1");
|
||||
|
||||
var canonicalsA = await readerA.StreamCanonicalsAsync().ToListAsync();
|
||||
var canonicalsB = await readerB.StreamCanonicalsAsync().ToListAsync();
|
||||
|
||||
canonicalsA.Should().HaveCount(5);
|
||||
canonicalsB.Should().HaveCount(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiSite_CursorsAreIndependent()
|
||||
{
|
||||
// Arrange - Sites with different cursors
|
||||
var sites = new[]
|
||||
{
|
||||
("site-alpha", "2025-01-15T08:00:00.000Z#0100"),
|
||||
("site-beta", "2025-01-15T09:00:00.000Z#0050"),
|
||||
("site-gamma", "2025-01-15T10:00:00.000Z#0200")
|
||||
};
|
||||
|
||||
var readers = new List<BundleReader>();
|
||||
|
||||
foreach (var (siteId, cursor) in sites)
|
||||
{
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = siteId,
|
||||
ExportCursor = cursor,
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = $"sha256:{siteId}",
|
||||
Counts = new BundleCounts { Canonicals = 1 }
|
||||
};
|
||||
|
||||
var bundle = await CreateTestBundleAsync(manifest, 1, 0, 0);
|
||||
readers.Add(await BundleReader.ReadAsync(bundle));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Assert - Each site has independent cursor
|
||||
readers[0].Manifest.ExportCursor.Should().Contain("#0100");
|
||||
readers[1].Manifest.ExportCursor.Should().Contain("#0050");
|
||||
readers[2].Manifest.ExportCursor.Should().Contain("#0200");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var reader in readers)
|
||||
{
|
||||
reader.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiSite_SameMergeHash_DifferentSources()
|
||||
{
|
||||
// Arrange - Same vulnerability from different sites with same merge hash
|
||||
var mergeHash = "sha256:cve-2024-1234-express-4.0.0";
|
||||
|
||||
var siteAManifest = new BundleManifest
|
||||
{
|
||||
Version = "feedser-bundle/1.0",
|
||||
SiteId = "primary-site",
|
||||
ExportCursor = "cursor-a",
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
BundleHash = "sha256:primary",
|
||||
Counts = new BundleCounts { Canonicals = 1 }
|
||||
};
|
||||
|
||||
// Create bundle with specific merge hash
|
||||
var bundleA = await CreateTestBundleWithSpecificHashAsync(siteAManifest, mergeHash);
|
||||
|
||||
// Act
|
||||
using var reader = await BundleReader.ReadAsync(bundleA);
|
||||
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
|
||||
|
||||
// Assert
|
||||
canonicals.Should().HaveCount(1);
|
||||
canonicals[0].MergeHash.Should().Be(mergeHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiSite_FederationSiteInfo_TracksPerSiteState()
|
||||
{
|
||||
// This tests the data structures for tracking multi-site state
|
||||
// Arrange
|
||||
var sites = new List<FederationSiteInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SiteId = "us-west-1",
|
||||
DisplayName = "US West",
|
||||
Enabled = true,
|
||||
LastCursor = "2025-01-15T10:00:00.000Z#0100",
|
||||
LastSyncAt = DateTimeOffset.Parse("2025-01-15T10:00:00Z"),
|
||||
BundlesImported = 42
|
||||
},
|
||||
new()
|
||||
{
|
||||
SiteId = "eu-central-1",
|
||||
DisplayName = "EU Central",
|
||||
Enabled = true,
|
||||
LastCursor = "2025-01-15T09:00:00.000Z#0050",
|
||||
LastSyncAt = DateTimeOffset.Parse("2025-01-15T09:00:00Z"),
|
||||
BundlesImported = 38
|
||||
},
|
||||
new()
|
||||
{
|
||||
SiteId = "ap-south-1",
|
||||
DisplayName = "Asia Pacific",
|
||||
Enabled = false,
|
||||
LastCursor = null,
|
||||
LastSyncAt = null,
|
||||
BundlesImported = 0
|
||||
}
|
||||
};
|
||||
|
||||
// Assert - Per-site state tracked independently
|
||||
sites.Should().HaveCount(3);
|
||||
sites.Count(s => s.Enabled).Should().Be(2);
|
||||
sites.Sum(s => s.BundlesImported).Should().Be(80);
|
||||
sites.Single(s => s.SiteId == "ap-south-1").LastCursor.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Types
|
||||
|
||||
private sealed record FederationSiteInfo
|
||||
{
|
||||
public required string SiteId { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public string? LastCursor { get; init; }
|
||||
public DateTimeOffset? LastSyncAt { get; init; }
|
||||
public int BundlesImported { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<Stream> CreateTestBundleAsync(
|
||||
BundleManifest manifest,
|
||||
int canonicalCount,
|
||||
int edgeCount,
|
||||
int deletionCount)
|
||||
{
|
||||
var tarBuffer = new MemoryStream();
|
||||
|
||||
await using (var tarWriter = new TarWriter(tarBuffer, leaveOpen: true))
|
||||
{
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, BundleSerializer.Options);
|
||||
await WriteEntryAsync(tarWriter, "MANIFEST.json", manifestJson);
|
||||
|
||||
var canonicalsNdjson = new StringBuilder();
|
||||
for (var i = 1; i <= canonicalCount; i++)
|
||||
{
|
||||
var canonical = new CanonicalBundleLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = $"CVE-2024-{i:D4}",
|
||||
AffectsKey = $"pkg:generic/test{i}@1.0",
|
||||
MergeHash = $"sha256:hash{i}",
|
||||
Status = "active",
|
||||
Title = $"Test Advisory {i}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
canonicalsNdjson.AppendLine(JsonSerializer.Serialize(canonical, BundleSerializer.Options));
|
||||
}
|
||||
await WriteEntryAsync(tarWriter, "canonicals.ndjson", canonicalsNdjson.ToString());
|
||||
|
||||
var edgesNdjson = new StringBuilder();
|
||||
for (var i = 1; i <= edgeCount; i++)
|
||||
{
|
||||
var edge = new EdgeBundleLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Source = "nvd",
|
||||
SourceAdvisoryId = $"CVE-2024-{i:D4}",
|
||||
ContentHash = $"sha256:edge{i}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
edgesNdjson.AppendLine(JsonSerializer.Serialize(edge, BundleSerializer.Options));
|
||||
}
|
||||
await WriteEntryAsync(tarWriter, "edges.ndjson", edgesNdjson.ToString());
|
||||
|
||||
var deletionsNdjson = new StringBuilder();
|
||||
for (var i = 1; i <= deletionCount; i++)
|
||||
{
|
||||
var deletion = new DeletionBundleLine
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Reason = "rejected",
|
||||
DeletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
deletionsNdjson.AppendLine(JsonSerializer.Serialize(deletion, BundleSerializer.Options));
|
||||
}
|
||||
await WriteEntryAsync(tarWriter, "deletions.ndjson", deletionsNdjson.ToString());
|
||||
}
|
||||
|
||||
tarBuffer.Position = 0;
|
||||
|
||||
var compressedBuffer = new MemoryStream();
|
||||
await ZstdCompression.CompressAsync(tarBuffer, compressedBuffer);
|
||||
compressedBuffer.Position = 0;
|
||||
|
||||
_disposableStreams.Add(compressedBuffer);
|
||||
return compressedBuffer;
|
||||
}
|
||||
|
||||
private async Task<Stream> CreateTestBundleWithSpecificHashAsync(
|
||||
BundleManifest manifest,
|
||||
string mergeHash)
|
||||
{
|
||||
var tarBuffer = new MemoryStream();
|
||||
|
||||
await using (var tarWriter = new TarWriter(tarBuffer, leaveOpen: true))
|
||||
{
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, BundleSerializer.Options);
|
||||
await WriteEntryAsync(tarWriter, "MANIFEST.json", manifestJson);
|
||||
|
||||
var canonical = new CanonicalBundleLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/express@4.0.0",
|
||||
MergeHash = mergeHash,
|
||||
Status = "active",
|
||||
Title = "Express vulnerability",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var canonicalsNdjson = JsonSerializer.Serialize(canonical, BundleSerializer.Options) + "\n";
|
||||
await WriteEntryAsync(tarWriter, "canonicals.ndjson", canonicalsNdjson);
|
||||
await WriteEntryAsync(tarWriter, "edges.ndjson", "");
|
||||
await WriteEntryAsync(tarWriter, "deletions.ndjson", "");
|
||||
}
|
||||
|
||||
tarBuffer.Position = 0;
|
||||
|
||||
var compressedBuffer = new MemoryStream();
|
||||
await ZstdCompression.CompressAsync(tarBuffer, compressedBuffer);
|
||||
compressedBuffer.Position = 0;
|
||||
|
||||
_disposableStreams.Add(compressedBuffer);
|
||||
return compressedBuffer;
|
||||
}
|
||||
|
||||
private static async Task WriteEntryAsync(TarWriter tarWriter, string name, string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
|
||||
{
|
||||
DataStream = new MemoryStream(bytes)
|
||||
};
|
||||
await tarWriter.WriteEntryAsync(entry);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user