674 lines
22 KiB
C#
674 lines
22 KiB
C#
// <copyright file="SignedSbomArchiveBuilderTests.cs" company="StellaOps">
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011)
|
|
// </copyright>
|
|
|
|
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
|
|
using StellaOps.Scanner.WebService.Domain;
|
|
using StellaOps.Scanner.WebService.Services;
|
|
using StellaOps.TestKit;
|
|
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
|
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="SignedSbomArchiveBuilder"/>.
|
|
/// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011)
|
|
/// </summary>
|
|
[Trait("Category", TestCategories.Unit)]
|
|
public sealed class SignedSbomArchiveBuilderTests : IDisposable
|
|
{
|
|
private static readonly DateTimeOffset FixedTime = new(2026, 1, 16, 10, 30, 0, TimeSpan.Zero);
|
|
private readonly SignedSbomArchiveBuilder _builder;
|
|
private readonly List<Stream> _streamsToDispose = new();
|
|
|
|
public SignedSbomArchiveBuilderTests()
|
|
{
|
|
var timeProvider = new FakeTimeProvider(FixedTime);
|
|
_builder = new SignedSbomArchiveBuilder(timeProvider, NullLogger<SignedSbomArchiveBuilder>.Instance);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var stream in _streamsToDispose)
|
|
{
|
|
stream.Dispose();
|
|
}
|
|
}
|
|
|
|
#region Archive Structure Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithMinimalInput_CreatesValidArchive()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.True(result.Size > 0);
|
|
Assert.StartsWith("signed-sbom-", result.FileName);
|
|
Assert.EndsWith(".tar.gz", result.FileName);
|
|
Assert.Equal("application/gzip", result.ContentType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_IncludesMandatoryFiles()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert - Extract and verify file list
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
|
|
Assert.Contains(files, f => f.EndsWith("manifest.json"));
|
|
Assert.Contains(files, f => f.EndsWith("metadata.json"));
|
|
Assert.Contains(files, f => f.EndsWith("sbom.spdx.json") || f.EndsWith("sbom.cdx.json"));
|
|
Assert.Contains(files, f => f.EndsWith("sbom.dsse.json"));
|
|
Assert.Contains(files, f => f.EndsWith("certs/signing-cert.pem"));
|
|
Assert.Contains(files, f => f.EndsWith("VERIFY.md"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithSpdxFormat_UsesSpdxFileName()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with { SbomFormat = "spdx-2.3" };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("sbom.spdx.json"));
|
|
Assert.DoesNotContain(files, f => f.EndsWith("sbom.cdx.json"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithCycloneDxFormat_UsesCdxFileName()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with { SbomFormat = "cyclonedx-1.7" };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("sbom.cdx.json"));
|
|
Assert.DoesNotContain(files, f => f.EndsWith("sbom.spdx.json"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Optional Content Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithSigningChain_IncludesChainFile()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
SigningChainPem = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----"
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("certs/signing-chain.pem"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithFulcioRoot_IncludesFulcioRootFile()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
FulcioRootPem = "-----BEGIN CERTIFICATE-----\nFULCIO\n-----END CERTIFICATE-----"
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("certs/fulcio-root.pem"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithRekorProof_IncludesRekorFiles()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
IncludeRekorProof = true,
|
|
RekorInclusionProofBytes = Encoding.UTF8.GetBytes("{\"proof\": \"test\"}"),
|
|
RekorCheckpointBytes = Encoding.UTF8.GetBytes("checkpoint"),
|
|
RekorPublicKeyPem = "-----BEGIN PUBLIC KEY-----\nREKOR\n-----END PUBLIC KEY-----",
|
|
RekorLogIndex = 12345678
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("rekor-proof/inclusion-proof.json"));
|
|
Assert.Contains(files, f => f.EndsWith("rekor-proof/checkpoint.sig"));
|
|
Assert.Contains(files, f => f.EndsWith("rekor-proof/rekor-public.pem"));
|
|
Assert.Equal(12345678, result.RekorLogIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithRekorProofDisabled_ExcludesRekorFiles()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
IncludeRekorProof = false
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.DoesNotContain(files, f => f.Contains("rekor-proof/"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithSchemas_IncludesSchemasReadme()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with { IncludeSchemas = true };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.Contains(files, f => f.EndsWith("schemas/README.md"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithoutSchemas_ExcludesSchemasDirectory()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with { IncludeSchemas = false };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var files = await ExtractTarGzFileListAsync(result.Stream);
|
|
Assert.DoesNotContain(files, f => f.Contains("schemas/"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Digest and Hash Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_ComputesCorrectSbomDigest()
|
|
{
|
|
// Arrange
|
|
var sbomContent = "{\"spdxVersion\": \"SPDX-2.3\"}";
|
|
var sbomBytes = Encoding.UTF8.GetBytes(sbomContent);
|
|
var expectedDigest = ComputeSha256Hex(sbomBytes);
|
|
|
|
var request = CreateMinimalRequest() with { SbomBytes = sbomBytes };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
Assert.Equal(expectedDigest, result.SbomDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_ComputesNonEmptyArchiveDigest()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.ArchiveDigest);
|
|
Assert.Equal(64, result.ArchiveDigest.Length); // SHA-256 hex string length
|
|
Assert.Matches("^[a-f0-9]{64}$", result.ArchiveDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_ComputesNonEmptyMerkleRoot()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.MerkleRoot);
|
|
Assert.StartsWith("sha256:", result.MerkleRoot);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Determinism Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_SameInput_ProducesSameSbomDigest()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result1 = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result1.Stream);
|
|
|
|
var result2 = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result2.Stream);
|
|
|
|
// Assert
|
|
Assert.Equal(result1.SbomDigest, result2.SbomDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_SameInput_ProducesSameMerkleRoot()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result1 = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result1.Stream);
|
|
|
|
var result2 = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result2.Stream);
|
|
|
|
// Assert
|
|
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Metadata Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_MetadataContainsRequiredFields()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
ImageRef = "ghcr.io/test/image:v1.0.0",
|
|
ImageDigest = "sha256:abc123",
|
|
SbomFormat = "spdx-2.3",
|
|
ComponentCount = 10,
|
|
PackageCount = 5,
|
|
FileCount = 100,
|
|
SignatureIssuer = "https://accounts.google.com",
|
|
SignatureSubject = "test@example.com"
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert - Extract and parse metadata.json
|
|
var metadataJson = await ExtractFileContentAsync(result.Stream, "metadata.json");
|
|
Assert.NotNull(metadataJson);
|
|
|
|
var metadata = JsonSerializer.Deserialize<JsonElement>(metadataJson);
|
|
|
|
Assert.Equal("1.0.0", metadata.GetProperty("schemaVersion").GetString());
|
|
Assert.True(metadata.TryGetProperty("stellaOps", out _));
|
|
Assert.True(metadata.TryGetProperty("generation", out _));
|
|
Assert.True(metadata.TryGetProperty("input", out _));
|
|
Assert.True(metadata.TryGetProperty("sbom", out _));
|
|
Assert.True(metadata.TryGetProperty("signature", out _));
|
|
|
|
var input = metadata.GetProperty("input");
|
|
Assert.Equal("ghcr.io/test/image:v1.0.0", input.GetProperty("imageRef").GetString());
|
|
Assert.Equal("sha256:abc123", input.GetProperty("imageDigest").GetString());
|
|
|
|
var sbom = metadata.GetProperty("sbom");
|
|
Assert.Equal("spdx-2.3", sbom.GetProperty("format").GetString());
|
|
Assert.Equal(10, sbom.GetProperty("componentCount").GetInt32());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Manifest Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_ManifestListsAllFiles()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with { IncludeSchemas = true };
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert - Extract and parse manifest.json
|
|
var manifestJson = await ExtractFileContentAsync(result.Stream, "manifest.json");
|
|
Assert.NotNull(manifestJson);
|
|
|
|
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
|
|
|
Assert.Equal("1.0.0", manifest.GetProperty("schemaVersion").GetString());
|
|
Assert.True(manifest.TryGetProperty("archiveId", out _));
|
|
Assert.True(manifest.TryGetProperty("generatedAt", out _));
|
|
Assert.True(manifest.TryGetProperty("files", out _));
|
|
Assert.True(manifest.TryGetProperty("merkleRoot", out _));
|
|
Assert.True(manifest.TryGetProperty("totalFiles", out _));
|
|
Assert.True(manifest.TryGetProperty("totalSize", out _));
|
|
|
|
var files = manifest.GetProperty("files");
|
|
Assert.True(files.GetArrayLength() > 0);
|
|
|
|
// Verify each file entry has required fields
|
|
foreach (var file in files.EnumerateArray())
|
|
{
|
|
Assert.True(file.TryGetProperty("path", out _));
|
|
Assert.True(file.TryGetProperty("sha256", out _));
|
|
Assert.True(file.TryGetProperty("size", out _));
|
|
Assert.True(file.TryGetProperty("mediaType", out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_ManifestFileHashesAreValid()
|
|
{
|
|
// Arrange
|
|
var sbomContent = "{\"test\": \"sbom\"}";
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
SbomBytes = Encoding.UTF8.GetBytes(sbomContent),
|
|
SbomFormat = "spdx-2.3"
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var manifestJson = await ExtractFileContentAsync(result.Stream, "manifest.json");
|
|
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
|
|
|
var files = manifest.GetProperty("files");
|
|
var sbomEntry = files.EnumerateArray()
|
|
.FirstOrDefault(f => f.GetProperty("path").GetString()?.EndsWith("sbom.spdx.json") == true);
|
|
|
|
Assert.NotNull(sbomEntry.GetProperty("sha256").GetString());
|
|
|
|
// Verify SBOM hash matches expected
|
|
var expectedHash = ComputeSha256Hex(Encoding.UTF8.GetBytes(sbomContent));
|
|
Assert.Equal(expectedHash, sbomEntry.GetProperty("sha256").GetString());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region VERIFY.md Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_VerifyMdContainsVerificationInstructions()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
SbomFormat = "spdx-2.3",
|
|
RekorLogIndex = 12345678
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md");
|
|
Assert.NotNull(verifyMd);
|
|
|
|
Assert.Contains("# SBOM Archive Verification", verifyMd);
|
|
Assert.Contains("Quick Verification", verifyMd);
|
|
Assert.Contains("Signature Verification", verifyMd);
|
|
Assert.Contains("cosign verify-blob", verifyMd);
|
|
Assert.Contains("sbom.spdx.json", verifyMd);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_VerifyMdIncludesRekorSectionWhenAvailable()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest() with
|
|
{
|
|
IncludeRekorProof = true,
|
|
RekorLogIndex = 12345678
|
|
};
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md");
|
|
|
|
Assert.Contains("Rekor Transparency Log", verifyMd);
|
|
Assert.Contains("12345678", verifyMd);
|
|
Assert.Contains("rekor-cli verify", verifyMd);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_VerifyMdIncludesFileHashTable()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
|
|
// Act
|
|
var result = await _builder.BuildAsync(request);
|
|
_streamsToDispose.Add(result.Stream);
|
|
|
|
// Assert
|
|
var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md");
|
|
|
|
Assert.Contains("Archive Contents", verifyMd);
|
|
Assert.Contains("| File | Size | SHA-256 |", verifyMd);
|
|
Assert.Contains("Merkle Root", verifyMd);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling Tests
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_WithNullRequest_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() => _builder.BuildAsync(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildAsync_SupportsCancellation()
|
|
{
|
|
// Arrange
|
|
var request = CreateMinimalRequest();
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<OperationCanceledException>(
|
|
() => _builder.BuildAsync(request, cts.Token));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Helpers
|
|
|
|
private static SignedSbomArchiveRequest CreateMinimalRequest()
|
|
{
|
|
var sbomBytes = Encoding.UTF8.GetBytes("{\"spdxVersion\": \"SPDX-2.3\", \"packages\": []}");
|
|
var dsseBytes = Encoding.UTF8.GetBytes("""
|
|
{
|
|
"payloadType": "application/vnd.in-toto+json",
|
|
"payload": "base64-encoded-payload",
|
|
"signatures": [{"sig": "test-signature"}]
|
|
}
|
|
""");
|
|
var certPem = """
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIBkTCB+wIJAKHBfFmJ/r7CMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl
|
|
c3RjYTAeFw0yNjAxMTYwMDAwMDBaFw0yNzAxMTYwMDAwMDBaMBExDzANBgNVBAMM
|
|
BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5Q2QRqzFVcFm5AwQKDQCu
|
|
xK5nMPVPu9F4Nz7Q3z5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F
|
|
AgMBAAGjUDBOMB0GA1UdDgQWBBQExample0MB8GA1UdIwQYMBaAFExample0MAwGA
|
|
1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADQQExample
|
|
-----END CERTIFICATE-----
|
|
""";
|
|
|
|
return new SignedSbomArchiveRequest
|
|
{
|
|
ScanId = new ScanId("scan-test-001"),
|
|
SbomBytes = sbomBytes,
|
|
SbomFormat = "spdx-2.3",
|
|
DsseEnvelopeBytes = dsseBytes,
|
|
SigningCertPem = certPem,
|
|
ImageRef = "ghcr.io/test/image:latest",
|
|
ImageDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
|
ComponentCount = 5,
|
|
PackageCount = 3,
|
|
FileCount = 20,
|
|
IncludeRekorProof = false,
|
|
IncludeSchemas = false
|
|
};
|
|
}
|
|
|
|
private static async Task<List<string>> ExtractTarGzFileListAsync(Stream stream)
|
|
{
|
|
var files = new List<string>();
|
|
stream.Position = 0;
|
|
|
|
await using var gzipStream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
|
|
using var memoryStream = new MemoryStream();
|
|
await gzipStream.CopyToAsync(memoryStream);
|
|
|
|
memoryStream.Position = 0;
|
|
var buffer = new byte[512];
|
|
|
|
while (memoryStream.Position < memoryStream.Length - 1024)
|
|
{
|
|
var bytesRead = await memoryStream.ReadAsync(buffer.AsMemory(0, 512));
|
|
if (bytesRead < 512) break;
|
|
|
|
// Check for end-of-archive marker (all zeros)
|
|
if (buffer.All(b => b == 0)) break;
|
|
|
|
// Extract file name from header (first 100 bytes)
|
|
var nameEnd = Array.IndexOf(buffer, (byte)0);
|
|
if (nameEnd < 0) nameEnd = 100;
|
|
var fileName = Encoding.ASCII.GetString(buffer, 0, Math.Min(nameEnd, 100)).TrimEnd('\0');
|
|
|
|
if (!string.IsNullOrEmpty(fileName))
|
|
{
|
|
files.Add(fileName);
|
|
}
|
|
|
|
// Get file size from header (bytes 124-135, octal)
|
|
var sizeStr = Encoding.ASCII.GetString(buffer, 124, 11).Trim('\0', ' ');
|
|
var fileSize = string.IsNullOrEmpty(sizeStr) ? 0 : Convert.ToInt64(sizeStr, 8);
|
|
|
|
// Skip file content (rounded up to 512-byte boundary)
|
|
var paddedSize = ((fileSize + 511) / 512) * 512;
|
|
memoryStream.Position += paddedSize;
|
|
}
|
|
|
|
stream.Position = 0;
|
|
return files;
|
|
}
|
|
|
|
private static async Task<string?> ExtractFileContentAsync(Stream stream, string fileNamePattern)
|
|
{
|
|
stream.Position = 0;
|
|
|
|
await using var gzipStream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
|
|
using var memoryStream = new MemoryStream();
|
|
await gzipStream.CopyToAsync(memoryStream);
|
|
|
|
memoryStream.Position = 0;
|
|
var headerBuffer = new byte[512];
|
|
|
|
while (memoryStream.Position < memoryStream.Length - 1024)
|
|
{
|
|
var bytesRead = await memoryStream.ReadAsync(headerBuffer.AsMemory(0, 512));
|
|
if (bytesRead < 512) break;
|
|
|
|
// Check for end-of-archive marker
|
|
if (headerBuffer.All(b => b == 0)) break;
|
|
|
|
// Extract file name
|
|
var nameEnd = Array.IndexOf(headerBuffer, (byte)0);
|
|
if (nameEnd < 0) nameEnd = 100;
|
|
var fileName = Encoding.ASCII.GetString(headerBuffer, 0, Math.Min(nameEnd, 100)).TrimEnd('\0');
|
|
|
|
// Get file size
|
|
var sizeStr = Encoding.ASCII.GetString(headerBuffer, 124, 11).Trim('\0', ' ');
|
|
var fileSize = string.IsNullOrEmpty(sizeStr) ? 0 : Convert.ToInt64(sizeStr, 8);
|
|
|
|
if (fileName.EndsWith(fileNamePattern))
|
|
{
|
|
var contentBuffer = new byte[fileSize];
|
|
await memoryStream.ReadAsync(contentBuffer.AsMemory(0, (int)fileSize));
|
|
stream.Position = 0;
|
|
return Encoding.UTF8.GetString(contentBuffer);
|
|
}
|
|
|
|
// Skip file content
|
|
var paddedSize = ((fileSize + 511) / 512) * 512;
|
|
memoryStream.Position += paddedSize - fileSize; // We haven't read content, so skip entire padded block
|
|
memoryStream.Position += fileSize;
|
|
}
|
|
|
|
stream.Position = 0;
|
|
return null;
|
|
}
|
|
|
|
private static string ComputeSha256Hex(byte[] data)
|
|
{
|
|
var hash = SHA256.HashData(data);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
#endregion
|
|
}
|