Implement Advisory Canonicalization and Backfill Migration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added AdvisoryCanonicalizer for canonicalizing advisory identifiers.
- Created EnsureAdvisoryCanonicalKeyBackfillMigration to populate advisory_key and links in advisory_raw documents.
- Introduced FileSurfaceManifestStore for managing surface manifests with file system backing.
- Developed ISurfaceManifestReader and ISurfaceManifestWriter interfaces for reading and writing manifests.
- Implemented SurfaceManifestPathBuilder for constructing paths and URIs for surface manifests.
- Added tests for FileSurfaceManifestStore to ensure correct functionality and deterministic behavior.
- Updated documentation for new features and migration steps.
This commit is contained in:
master
2025-11-07 19:54:02 +02:00
parent a1ce3f74fa
commit 515975edc5
42 changed files with 1893 additions and 336 deletions

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Surface.FS;
using Xunit;
namespace StellaOps.Scanner.Surface.FS.Tests;
public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
{
private readonly DirectoryInfo _root;
private readonly FileSurfaceManifestStore _store;
public FileSurfaceManifestStoreTests()
{
_root = Directory.CreateTempSubdirectory("surface-fs-tests");
var cacheOptions = Options.Create(new SurfaceCacheOptions
{
RootDirectory = Path.Combine(_root.FullName, "cache")
});
var manifestOptions = Options.Create(new SurfaceManifestStoreOptions
{
RootDirectory = Path.Combine(_root.FullName, "manifests"),
Bucket = "test-bucket",
Prefix = "manifests"
});
_store = new FileSurfaceManifestStore(
cacheOptions,
manifestOptions,
NullLogger<FileSurfaceManifestStore>.Instance);
}
[Fact]
public async Task PublishAsync_WritesManifestWithDeterministicDigest()
{
var doc = new SurfaceManifestDocument
{
Tenant = "acme",
ImageDigest = "sha256:deadbeef",
Artifacts = new[]
{
new SurfaceManifestArtifact
{
Kind = "layer",
Uri = "cas://bucket/layer",
Digest = "sha256:aaaa",
MediaType = "application/json",
Format = "json",
Metadata = new Dictionary<string, string>
{
["z"] = "last",
["a"] = "first"
}
},
new SurfaceManifestArtifact
{
Kind = "entrytrace",
Uri = "cas://bucket/entry",
Digest = "sha256:bbbb",
MediaType = "application/json",
Format = "json"
}
}
};
var result = await _store.PublishAsync(doc);
Assert.StartsWith("sha256:", result.ManifestDigest, StringComparison.Ordinal);
Assert.Equal(result.ManifestDigest, $"sha256:{result.ManifestUri.Split('/', StringSplitOptions.RemoveEmptyEntries).Last()[..^5]}");
Assert.NotNull(result.Document);
Assert.True(File.Exists(GetManifestPath(result.ManifestDigest, "acme")));
// Metadata dictionary should be sorted to guarantee deterministic serialization
var artifact = result.Document.Artifacts.Single(a => a.Kind == "layer");
Assert.Equal(new[] { "a", "z" }, artifact.Metadata!.Keys);
}
[Fact]
public async Task TryGetByUriAsync_ReturnsPublishedManifest()
{
var doc = new SurfaceManifestDocument
{
Tenant = "acme",
ScanId = "scan-123",
Artifacts = Array.Empty<SurfaceManifestArtifact>()
};
var publish = await _store.PublishAsync(doc);
var retrieved = await _store.TryGetByUriAsync(publish.ManifestUri);
Assert.NotNull(retrieved);
Assert.Equal("acme", retrieved!.Tenant);
Assert.Equal("scan-123", retrieved.ScanId);
}
[Fact]
public async Task TryGetByDigestAsync_ReturnsManifestAcrossTenants()
{
var doc1 = new SurfaceManifestDocument
{
Tenant = "tenant-one",
Artifacts = Array.Empty<SurfaceManifestArtifact>()
};
var doc2 = new SurfaceManifestDocument
{
Tenant = "tenant-two",
Artifacts = Array.Empty<SurfaceManifestArtifact>()
};
var publish1 = await _store.PublishAsync(doc1);
var publish2 = await _store.PublishAsync(doc2);
var retrieved = await _store.TryGetByDigestAsync(publish2.ManifestDigest);
Assert.NotNull(retrieved);
Assert.Equal("tenant-two", retrieved!.Tenant);
}
private string GetManifestPath(string digest, string tenant)
{
var hex = digest["sha256:".Length..];
return Path.Combine(
Path.Combine(_root.FullName, "manifests"),
tenant,
hex[..2],
hex[2..4],
$"{hex}.json");
}
public async ValueTask DisposeAsync()
{
await Task.Run(() =>
{
try
{
if (_root.Exists)
{
_root.Delete(recursive: true);
}
}
catch
{
// ignored
}
});
}
}