up
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// DPoP replay cache backed by <see cref="IIdempotencyStore"/>.
|
||||
/// Supports any transport (InMemory, Valkey, PostgreSQL) via factory injection.
|
||||
/// </summary>
|
||||
public sealed class MessagingDpopReplayCache : IDpopReplayCache
|
||||
{
|
||||
private readonly IIdempotencyStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public MessagingDpopReplayCache(
|
||||
IIdempotencyStoreFactory storeFactory,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storeFactory);
|
||||
|
||||
_store = storeFactory.Create("dpop:replay");
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> TryStoreAsync(
|
||||
string jwtId,
|
||||
DateTimeOffset expiresAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ttl = expiresAt - now;
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
// Already expired, treat as valid to store (won't conflict)
|
||||
return true;
|
||||
}
|
||||
|
||||
var result = await _store.TryClaimAsync(jwtId, jwtId, ttl, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsFirstClaim;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Xunit;
|
||||
public class ReplayManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializesWithNamespacesAndAnalysis()
|
||||
public void SerializesWithNamespacesAndAnalysis_V1()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
@@ -20,7 +20,9 @@ public class ReplayManifestTests
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://reachability_graphs/aa/aagraph.tar.zst",
|
||||
Sha256 = "aa",
|
||||
Hash = "sha256:aa",
|
||||
HashAlgorithm = "sha256",
|
||||
Sha256 = "aa", // Legacy field for v1 compat
|
||||
Namespace = "reachability_graphs",
|
||||
CallgraphId = "cg-1",
|
||||
Analyzer = "scanner",
|
||||
@@ -31,7 +33,9 @@ public class ReplayManifestTests
|
||||
{
|
||||
Source = "runtime",
|
||||
CasUri = "cas://runtime_traces/bb/bbtrace.tar.zst",
|
||||
Sha256 = "bb",
|
||||
Hash = "sha256:bb",
|
||||
HashAlgorithm = "sha256",
|
||||
Sha256 = "bb", // Legacy field for v1 compat
|
||||
Namespace = "runtime_traces",
|
||||
RecordedAt = System.DateTimeOffset.Parse("2025-11-26T00:00:00Z")
|
||||
});
|
||||
@@ -43,4 +47,36 @@ public class ReplayManifestTests
|
||||
Assert.Contains("\"callgraphId\":\"cg-1\"", json);
|
||||
Assert.Contains("\"namespace\":\"runtime_traces\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializesWithV2HashFields()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
AnalysisId = "analysis-v2"
|
||||
}
|
||||
};
|
||||
|
||||
manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://reachability/graphs/blake3:abc123",
|
||||
Hash = "blake3:abc123def456789012345678901234567890123456789012345678901234",
|
||||
HashAlgorithm = "blake3-256",
|
||||
Namespace = "reachability_graphs",
|
||||
Analyzer = "scanner.java@10.0.0",
|
||||
Version = "10.0.0"
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
|
||||
Assert.Contains("\"hash\":\"blake3:", json);
|
||||
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
|
||||
// v2 manifests should not emit legacy sha256 field (JsonIgnore when null)
|
||||
Assert.DoesNotContain("\"sha256\":", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test vectors from replay-manifest-v2-acceptance.md
|
||||
/// </summary>
|
||||
public class ReplayManifestV2Tests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
#region Section 4.1: Minimal Valid Manifest v2
|
||||
|
||||
[Fact]
|
||||
public void MinimalValidManifestV2_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-001",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T10:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
||||
}
|
||||
},
|
||||
RuntimeTraces = new List<ReplayReachabilityTraceReference>(),
|
||||
CodeIdCoverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 100,
|
||||
NodesWithSymbolId = 100,
|
||||
NodesWithCodeId = 0,
|
||||
CoveragePercent = 100.0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
|
||||
Assert.Contains("\"hash\":\"blake3:", json);
|
||||
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
|
||||
Assert.Contains("\"code_id_coverage\"", json);
|
||||
Assert.Contains("\"total_nodes\":100", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.2: Manifest with Runtime Traces
|
||||
|
||||
[Fact]
|
||||
public void ManifestWithRuntimeTraces_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-002",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T11:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
}
|
||||
},
|
||||
RuntimeTraces = new List<ReplayReachabilityTraceReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Source = "eventpipe",
|
||||
Hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
HashAlgorithm = "sha256",
|
||||
CasUri = "cas://reachability/runtime/sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
RecordedAt = DateTimeOffset.Parse("2025-12-13T10:30:00Z")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
Assert.Contains("\"source\":\"eventpipe\"", json);
|
||||
Assert.Contains("\"hash\":\"sha256:", json);
|
||||
Assert.Contains("\"hashAlg\":\"sha256\"", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.3: Sorting Validation
|
||||
|
||||
[Fact]
|
||||
public void SortingValidation_UnsortedGraphs_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
},
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.UnsortedEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortingValidation_SortedGraphs_PassesValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
},
|
||||
new()
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.4: Invalid Manifest Vectors
|
||||
|
||||
[Fact]
|
||||
public void InvalidManifest_MissingSchemaVersion_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = null!
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidManifest_VersionMismatch_WhenV2Required_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(requireV2: true);
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.VersionMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidManifest_MissingHashAlg_InV2_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = null!, // Missing
|
||||
CasUri = "cas://reachability/graphs/blake3:..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingHashAlg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidManifest_MissingCasReference_FailsValidation()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
// Don't register any objects
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:missing"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.CasNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidManifest_HashMismatch_FailsValidation()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual",
|
||||
"blake3:differenthash");
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual.dsse",
|
||||
"blake3:differenthash.dsse");
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:expected",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.HashMismatch);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 5: Migration Path
|
||||
|
||||
[Fact]
|
||||
public void UpgradeToV2_ConvertsV1ManifestCorrectly()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-legacy"
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://reachability/graphs/abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, v2.SchemaVersion);
|
||||
Assert.Single(v2.Reachability.Graphs);
|
||||
Assert.Equal("sha256:abc123", v2.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", v2.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeToV2_SortsGraphsByUri()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new() { Sha256 = "zzz", CasUri = "cas://graphs/zzz" },
|
||||
new() { Sha256 = "aaa", CasUri = "cas://graphs/aaa" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal("cas://graphs/aaa", v2.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzz", v2.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReachabilityReplayWriter Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithValidGraphs_CreatesSortedManifest()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:zzzz",
|
||||
CasUri = "cas://graphs/zzzz"
|
||||
},
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:aaaa",
|
||||
CasUri = "cas://graphs/aaaa"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, manifest.SchemaVersion);
|
||||
Assert.Equal("cas://graphs/aaaa", manifest.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzzz", manifest.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithLegacySha256_MigratesHashField()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://graphs/abc123"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("sha256:abc123", manifest.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildManifestV2_InfersHashAlgorithmFromPrefix()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4",
|
||||
CasUri = "cas://graphs/a1b2c3d4"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("blake3-256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildManifestV2_RequiresAtLeastOneGraph()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
Array.Empty<ReplayReachabilityGraphReference>(),
|
||||
Array.Empty<ReplayReachabilityTraceReference>()));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CodeIdCoverage Tests
|
||||
|
||||
[Fact]
|
||||
public void CodeIdCoverage_SerializesWithSnakeCaseKeys()
|
||||
{
|
||||
var coverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 1247,
|
||||
NodesWithSymbolId = 1189,
|
||||
NodesWithCodeId = 58,
|
||||
CoveragePercent = 100.0
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(coverage, JsonOptions);
|
||||
|
||||
Assert.Contains("\"total_nodes\":1247", json);
|
||||
Assert.Contains("\"nodes_with_symbol_id\":1189", json);
|
||||
Assert.Contains("\"nodes_with_code_id\":58", json);
|
||||
Assert.Contains("\"coverage_percent\":100", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
117
src/__Libraries/StellaOps.Replay.Core/CasValidator.cs
Normal file
117
src/__Libraries/StellaOps.Replay.Core/CasValidator.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Validates CAS references before manifest signing.
|
||||
/// </summary>
|
||||
public interface ICasValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a CAS URI exists and matches the expected hash.
|
||||
/// </summary>
|
||||
Task<CasValidationResult> ValidateAsync(string casUri, string expectedHash);
|
||||
|
||||
/// <summary>
|
||||
/// Validates multiple CAS references in batch.
|
||||
/// </summary>
|
||||
Task<CasValidationResult> ValidateBatchAsync(IEnumerable<CasReference> references);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reference to a CAS object for validation.
|
||||
/// </summary>
|
||||
public sealed record CasReference(
|
||||
string CasUri,
|
||||
string ExpectedHash,
|
||||
string? HashAlgorithm = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a CAS validation operation.
|
||||
/// </summary>
|
||||
public sealed record CasValidationResult(
|
||||
bool IsValid,
|
||||
string? ActualHash = null,
|
||||
string? Error = null,
|
||||
IReadOnlyList<CasValidationError>? Errors = null
|
||||
)
|
||||
{
|
||||
public static CasValidationResult Success(string actualHash) =>
|
||||
new(true, actualHash);
|
||||
|
||||
public static CasValidationResult Failure(string error) =>
|
||||
new(false, Error: error);
|
||||
|
||||
public static CasValidationResult NotFound(string casUri) =>
|
||||
new(false, Error: $"CAS object not found: {casUri}");
|
||||
|
||||
public static CasValidationResult HashMismatch(string casUri, string expected, string actual) =>
|
||||
new(false, ActualHash: actual, Error: $"Hash mismatch for {casUri}: expected {expected}, got {actual}");
|
||||
|
||||
public static CasValidationResult BatchResult(bool isValid, IReadOnlyList<CasValidationError> errors) =>
|
||||
new(isValid, Errors: errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error details for a single CAS validation failure in a batch.
|
||||
/// </summary>
|
||||
public sealed record CasValidationError(
|
||||
string CasUri,
|
||||
string ErrorCode,
|
||||
string Message
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory CAS validator for testing and offline scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCasValidator : ICasValidator
|
||||
{
|
||||
private readonly Dictionary<string, string> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a CAS object for validation.
|
||||
/// </summary>
|
||||
public void Register(string casUri, string hash)
|
||||
{
|
||||
_objects[casUri] = hash;
|
||||
}
|
||||
|
||||
public Task<CasValidationResult> ValidateAsync(string casUri, string expectedHash)
|
||||
{
|
||||
if (!_objects.TryGetValue(casUri, out var actualHash))
|
||||
{
|
||||
return Task.FromResult(CasValidationResult.NotFound(casUri));
|
||||
}
|
||||
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CasValidationResult.HashMismatch(casUri, expectedHash, actualHash));
|
||||
}
|
||||
|
||||
return Task.FromResult(CasValidationResult.Success(actualHash));
|
||||
}
|
||||
|
||||
public async Task<CasValidationResult> ValidateBatchAsync(IEnumerable<CasReference> references)
|
||||
{
|
||||
var errors = new List<CasValidationError>();
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var result = await ValidateAsync(reference.CasUri, reference.ExpectedHash).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new CasValidationError(
|
||||
reference.CasUri,
|
||||
result.Error?.Contains("not found") == true
|
||||
? ReplayManifestErrorCodes.CasNotFound
|
||||
: ReplayManifestErrorCodes.HashMismatch,
|
||||
result.Error ?? "Unknown error"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return CasValidationResult.BatchResult(errors.Count == 0, errors);
|
||||
}
|
||||
}
|
||||
@@ -58,17 +58,43 @@ public static class ReachabilityReplayWriter
|
||||
throw new InvalidOperationException("Graph casUri is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.Sha256))
|
||||
// v2: Prefer Hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
throw new InvalidOperationException("Graph sha256 is required.");
|
||||
// Backward compat: migrate from legacy Sha256 field
|
||||
if (!string.IsNullOrWhiteSpace(graph.Sha256))
|
||||
{
|
||||
graph.Hash = $"sha256:{graph.Sha256}";
|
||||
graph.HashAlgorithm = "sha256";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Graph hash is required.");
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize hash algorithm from hash prefix if not explicitly set
|
||||
if (string.IsNullOrWhiteSpace(graph.HashAlgorithm))
|
||||
{
|
||||
graph.HashAlgorithm = InferHashAlgorithm(graph.Hash);
|
||||
}
|
||||
|
||||
graph.HashAlgorithm = string.IsNullOrWhiteSpace(graph.HashAlgorithm) ? "blake3-256" : graph.HashAlgorithm;
|
||||
graph.Kind = string.IsNullOrWhiteSpace(graph.Kind) ? "static" : graph.Kind;
|
||||
graph.Namespace = string.IsNullOrWhiteSpace(graph.Namespace) ? "reachability_graphs" : graph.Namespace;
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static string InferHashAlgorithm(string hash)
|
||||
{
|
||||
if (hash.StartsWith("blake3:", StringComparison.OrdinalIgnoreCase))
|
||||
return "blake3-256";
|
||||
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha256";
|
||||
if (hash.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha512";
|
||||
return "blake3-256"; // Default for v2
|
||||
}
|
||||
|
||||
private static ReplayReachabilityTraceReference NormalizeTrace(ReplayReachabilityTraceReference trace)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trace.CasUri))
|
||||
@@ -76,12 +102,27 @@ public static class ReachabilityReplayWriter
|
||||
throw new InvalidOperationException("Trace casUri is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.Sha256))
|
||||
// v2: Prefer Hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
throw new InvalidOperationException("Trace sha256 is required.");
|
||||
// Backward compat: migrate from legacy Sha256 field
|
||||
if (!string.IsNullOrWhiteSpace(trace.Sha256))
|
||||
{
|
||||
trace.Hash = $"sha256:{trace.Sha256}";
|
||||
trace.HashAlgorithm = "sha256";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Trace hash is required.");
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize hash algorithm from hash prefix if not explicitly set
|
||||
if (string.IsNullOrWhiteSpace(trace.HashAlgorithm))
|
||||
{
|
||||
trace.HashAlgorithm = InferHashAlgorithm(trace.Hash);
|
||||
}
|
||||
|
||||
trace.HashAlgorithm = string.IsNullOrWhiteSpace(trace.HashAlgorithm) ? "sha256" : trace.HashAlgorithm;
|
||||
trace.Namespace = string.IsNullOrWhiteSpace(trace.Namespace) ? "runtime_traces" : trace.Namespace;
|
||||
trace.Source = string.IsNullOrWhiteSpace(trace.Source) ? "runtime" : trace.Source;
|
||||
return trace;
|
||||
|
||||
@@ -47,6 +47,24 @@ public sealed class ReplayReachabilitySection
|
||||
|
||||
[JsonPropertyName("runtimeTraces")]
|
||||
public List<ReplayReachabilityTraceReference> RuntimeTraces { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("code_id_coverage")]
|
||||
public CodeIdCoverage? CodeIdCoverage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CodeIdCoverage
|
||||
{
|
||||
[JsonPropertyName("total_nodes")]
|
||||
public int TotalNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes_with_symbol_id")]
|
||||
public int NodesWithSymbolId { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes_with_code_id")]
|
||||
public int NodesWithCodeId { get; set; }
|
||||
|
||||
[JsonPropertyName("coverage_percent")]
|
||||
public double CoveragePercent { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReplayReachabilityGraphReference
|
||||
@@ -57,11 +75,22 @@ public sealed class ReplayReachabilityGraphReference
|
||||
[JsonPropertyName("casUri")]
|
||||
public string CasUri { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Hash with algorithm prefix, e.g., "blake3:a1b2c3d4..." or "sha256:feedface..."
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashAlg")]
|
||||
public string HashAlgorithm { get; set; } = "sha256";
|
||||
public string HashAlgorithm { get; set; } = "blake3-256";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy SHA-256 field for backward compatibility with v1 manifests.
|
||||
/// In v2, use the Hash field with algorithm prefix instead.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "reachability_graphs";
|
||||
@@ -84,12 +113,23 @@ public sealed class ReplayReachabilityTraceReference
|
||||
[JsonPropertyName("casUri")]
|
||||
public string CasUri { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Hash with algorithm prefix, e.g., "sha256:feedface..."
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashAlg")]
|
||||
public string HashAlgorithm { get; set; } = "sha256";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy SHA-256 field for backward compatibility with v1 manifests.
|
||||
/// In v2, use the Hash field with algorithm prefix instead.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "runtime_traces";
|
||||
|
||||
|
||||
397
src/__Libraries/StellaOps.Replay.Core/ReplayManifestValidator.cs
Normal file
397
src/__Libraries/StellaOps.Replay.Core/ReplayManifestValidator.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for replay manifest validation per acceptance contract.
|
||||
/// </summary>
|
||||
public static class ReplayManifestErrorCodes
|
||||
{
|
||||
public const string MissingVersion = "REPLAY_MANIFEST_MISSING_VERSION";
|
||||
public const string VersionMismatch = "REPLAY_MANIFEST_VERSION_MISMATCH";
|
||||
public const string MissingHashAlg = "REPLAY_MANIFEST_MISSING_HASH_ALG";
|
||||
public const string UnsortedEntries = "REPLAY_MANIFEST_UNSORTED_ENTRIES";
|
||||
public const string CasNotFound = "REPLAY_MANIFEST_CAS_NOT_FOUND";
|
||||
public const string HashMismatch = "REPLAY_MANIFEST_HASH_MISMATCH";
|
||||
public const string MissingHash = "REPLAY_MANIFEST_MISSING_HASH";
|
||||
public const string MissingCasUri = "REPLAY_MANIFEST_MISSING_CAS_URI";
|
||||
public const string InvalidHashFormat = "REPLAY_MANIFEST_INVALID_HASH_FORMAT";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest validation.
|
||||
/// </summary>
|
||||
public sealed record ManifestValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<ManifestValidationError> Errors
|
||||
)
|
||||
{
|
||||
public static ManifestValidationResult Success() =>
|
||||
new(true, Array.Empty<ManifestValidationError>());
|
||||
|
||||
public static ManifestValidationResult Failure(IEnumerable<ManifestValidationError> errors) =>
|
||||
new(false, errors.ToList());
|
||||
|
||||
public static ManifestValidationResult Failure(ManifestValidationError error) =>
|
||||
new(false, new[] { error });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single validation error.
|
||||
/// </summary>
|
||||
public sealed record ManifestValidationError(
|
||||
string ErrorCode,
|
||||
string Message,
|
||||
string? Path = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Validates replay manifests against v2 schema rules and CAS registration requirements.
|
||||
/// </summary>
|
||||
public sealed class ReplayManifestValidator
|
||||
{
|
||||
private readonly ICasValidator? _casValidator;
|
||||
private readonly bool _requireV2;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a validator with optional CAS validation.
|
||||
/// </summary>
|
||||
/// <param name="casValidator">Optional CAS validator for reference verification.</param>
|
||||
/// <param name="requireV2">If true, only v2 manifests are accepted.</param>
|
||||
public ReplayManifestValidator(ICasValidator? casValidator = null, bool requireV2 = false)
|
||||
{
|
||||
_casValidator = casValidator;
|
||||
_requireV2 = requireV2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a manifest against v2 schema rules.
|
||||
/// </summary>
|
||||
public async Task<ManifestValidationResult> ValidateAsync(ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
var errors = new List<ManifestValidationError>();
|
||||
|
||||
// 1. Validate schema version
|
||||
if (string.IsNullOrWhiteSpace(manifest.SchemaVersion))
|
||||
{
|
||||
errors.Add(new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingVersion,
|
||||
"schemaVersion is required",
|
||||
"schemaVersion"));
|
||||
}
|
||||
else if (_requireV2 && manifest.SchemaVersion != ReplayManifestVersions.V2)
|
||||
{
|
||||
errors.Add(new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.VersionMismatch,
|
||||
$"schemaVersion must be {ReplayManifestVersions.V2} when v2 is required",
|
||||
"schemaVersion"));
|
||||
}
|
||||
|
||||
// 2. Validate graph references
|
||||
var isV2 = manifest.SchemaVersion == ReplayManifestVersions.V2;
|
||||
var graphErrors = ValidateGraphs(manifest.Reachability?.Graphs, isV2);
|
||||
errors.AddRange(graphErrors);
|
||||
|
||||
// 3. Validate trace references
|
||||
var traceErrors = ValidateTraces(manifest.Reachability?.RuntimeTraces, isV2);
|
||||
errors.AddRange(traceErrors);
|
||||
|
||||
// 4. Validate sorting (v2 only)
|
||||
if (isV2)
|
||||
{
|
||||
var sortingErrors = ValidateSorting(manifest);
|
||||
errors.AddRange(sortingErrors);
|
||||
}
|
||||
|
||||
// 5. Validate CAS registration if validator provided
|
||||
if (_casValidator is not null && errors.Count == 0)
|
||||
{
|
||||
var casErrors = await ValidateCasReferencesAsync(manifest).ConfigureAwait(false);
|
||||
errors.AddRange(casErrors);
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ManifestValidationResult.Success()
|
||||
: ManifestValidationResult.Failure(errors);
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateGraphs(
|
||||
IReadOnlyList<ReplayReachabilityGraphReference>? graphs, bool isV2)
|
||||
{
|
||||
if (graphs is null || graphs.Count == 0)
|
||||
yield break;
|
||||
|
||||
for (var i = 0; i < graphs.Count; i++)
|
||||
{
|
||||
var graph = graphs[i];
|
||||
var path = $"reachability.graphs[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.CasUri))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingCasUri,
|
||||
"casUri is required",
|
||||
$"{path}.casUri");
|
||||
}
|
||||
|
||||
if (isV2)
|
||||
{
|
||||
// v2 requires hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHash,
|
||||
"hash is required in v2",
|
||||
$"{path}.hash");
|
||||
}
|
||||
else if (!graph.Hash.Contains(':'))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.InvalidHashFormat,
|
||||
"hash must include algorithm prefix (e.g., blake3:...)",
|
||||
$"{path}.hash");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.HashAlgorithm))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHashAlg,
|
||||
"hashAlg is required in v2",
|
||||
$"{path}.hashAlg");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateTraces(
|
||||
IReadOnlyList<ReplayReachabilityTraceReference>? traces, bool isV2)
|
||||
{
|
||||
if (traces is null || traces.Count == 0)
|
||||
yield break;
|
||||
|
||||
for (var i = 0; i < traces.Count; i++)
|
||||
{
|
||||
var trace = traces[i];
|
||||
var path = $"reachability.runtimeTraces[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.CasUri))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingCasUri,
|
||||
"casUri is required",
|
||||
$"{path}.casUri");
|
||||
}
|
||||
|
||||
if (isV2)
|
||||
{
|
||||
// v2 requires hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHash,
|
||||
"hash is required in v2",
|
||||
$"{path}.hash");
|
||||
}
|
||||
else if (!trace.Hash.Contains(':'))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.InvalidHashFormat,
|
||||
"hash must include algorithm prefix (e.g., sha256:...)",
|
||||
$"{path}.hash");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.HashAlgorithm))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHashAlg,
|
||||
"hashAlg is required in v2",
|
||||
$"{path}.hashAlg");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateSorting(ReplayManifest manifest)
|
||||
{
|
||||
var graphs = manifest.Reachability?.Graphs;
|
||||
if (graphs is not null && graphs.Count > 1)
|
||||
{
|
||||
var sorted = graphs.OrderBy(g => g.CasUri, StringComparer.Ordinal).ToList();
|
||||
for (var i = 0; i < graphs.Count; i++)
|
||||
{
|
||||
if (!string.Equals(graphs[i].CasUri, sorted[i].CasUri, StringComparison.Ordinal))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.UnsortedEntries,
|
||||
"reachability.graphs must be sorted by casUri (lexicographic)",
|
||||
"reachability.graphs");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var traces = manifest.Reachability?.RuntimeTraces;
|
||||
if (traces is not null && traces.Count > 1)
|
||||
{
|
||||
var sorted = traces.OrderBy(t => t.CasUri, StringComparer.Ordinal).ToList();
|
||||
for (var i = 0; i < traces.Count; i++)
|
||||
{
|
||||
if (!string.Equals(traces[i].CasUri, sorted[i].CasUri, StringComparison.Ordinal))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.UnsortedEntries,
|
||||
"reachability.runtimeTraces must be sorted by casUri (lexicographic)",
|
||||
"reachability.runtimeTraces");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ManifestValidationError>> ValidateCasReferencesAsync(ReplayManifest manifest)
|
||||
{
|
||||
var references = new List<CasReference>();
|
||||
|
||||
// Collect graph references
|
||||
if (manifest.Reachability?.Graphs is not null)
|
||||
{
|
||||
foreach (var graph in manifest.Reachability.Graphs)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(graph.CasUri) && !string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
references.Add(new CasReference(graph.CasUri, graph.Hash, graph.HashAlgorithm));
|
||||
|
||||
// Also check for DSSE envelope
|
||||
var dsseUri = $"{graph.CasUri}.dsse";
|
||||
references.Add(new CasReference(dsseUri, $"{graph.Hash}.dsse", graph.HashAlgorithm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect trace references
|
||||
if (manifest.Reachability?.RuntimeTraces is not null)
|
||||
{
|
||||
foreach (var trace in manifest.Reachability.RuntimeTraces)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trace.CasUri) && !string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
references.Add(new CasReference(trace.CasUri, trace.Hash, trace.HashAlgorithm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (references.Count == 0)
|
||||
return Array.Empty<ManifestValidationError>();
|
||||
|
||||
var result = await _casValidator!.ValidateBatchAsync(references).ConfigureAwait(false);
|
||||
if (result.IsValid)
|
||||
return Array.Empty<ManifestValidationError>();
|
||||
|
||||
return result.Errors?.Select(e => new ManifestValidationError(e.ErrorCode, e.Message, e.CasUri))
|
||||
?? Array.Empty<ManifestValidationError>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades a v1 manifest to v2 format.
|
||||
/// </summary>
|
||||
public static ReplayManifest UpgradeToV2(ReplayManifest v1)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(v1);
|
||||
|
||||
var v2 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = v1.Scan,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
AnalysisId = v1.Reachability?.AnalysisId,
|
||||
CodeIdCoverage = v1.Reachability?.CodeIdCoverage,
|
||||
Graphs = v1.Reachability?.Graphs?
|
||||
.Select(g => UpgradeGraph(g))
|
||||
.OrderBy(g => g.CasUri, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<ReplayReachabilityGraphReference>(),
|
||||
RuntimeTraces = v1.Reachability?.RuntimeTraces?
|
||||
.Select(t => UpgradeTrace(t))
|
||||
.OrderBy(t => t.CasUri, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<ReplayReachabilityTraceReference>()
|
||||
}
|
||||
};
|
||||
|
||||
return v2;
|
||||
}
|
||||
|
||||
private static ReplayReachabilityGraphReference UpgradeGraph(ReplayReachabilityGraphReference g)
|
||||
{
|
||||
var hash = g.Hash;
|
||||
var hashAlg = g.HashAlgorithm;
|
||||
|
||||
// If Hash is empty, derive from legacy Sha256
|
||||
if (string.IsNullOrWhiteSpace(hash) && !string.IsNullOrWhiteSpace(g.Sha256))
|
||||
{
|
||||
hash = $"sha256:{g.Sha256}";
|
||||
hashAlg = "sha256";
|
||||
}
|
||||
|
||||
// Infer hash algorithm from prefix if not set
|
||||
if (string.IsNullOrWhiteSpace(hashAlg) && !string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
hashAlg = InferHashAlgorithmFromPrefix(hash);
|
||||
}
|
||||
|
||||
return new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = g.Kind,
|
||||
CasUri = g.CasUri,
|
||||
Hash = hash ?? string.Empty,
|
||||
HashAlgorithm = hashAlg ?? "blake3-256",
|
||||
Namespace = g.Namespace,
|
||||
CallgraphId = g.CallgraphId,
|
||||
Analyzer = g.Analyzer,
|
||||
Version = g.Version
|
||||
};
|
||||
}
|
||||
|
||||
private static ReplayReachabilityTraceReference UpgradeTrace(ReplayReachabilityTraceReference t)
|
||||
{
|
||||
var hash = t.Hash;
|
||||
var hashAlg = t.HashAlgorithm;
|
||||
|
||||
// If Hash is empty, derive from legacy Sha256
|
||||
if (string.IsNullOrWhiteSpace(hash) && !string.IsNullOrWhiteSpace(t.Sha256))
|
||||
{
|
||||
hash = $"sha256:{t.Sha256}";
|
||||
hashAlg = "sha256";
|
||||
}
|
||||
|
||||
// Infer hash algorithm from prefix if not set
|
||||
if (string.IsNullOrWhiteSpace(hashAlg) && !string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
hashAlg = InferHashAlgorithmFromPrefix(hash);
|
||||
}
|
||||
|
||||
return new ReplayReachabilityTraceReference
|
||||
{
|
||||
Source = t.Source,
|
||||
CasUri = t.CasUri,
|
||||
Hash = hash ?? string.Empty,
|
||||
HashAlgorithm = hashAlg ?? "sha256",
|
||||
Namespace = t.Namespace,
|
||||
RecordedAt = t.RecordedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferHashAlgorithmFromPrefix(string hash)
|
||||
{
|
||||
if (hash.StartsWith("blake3:", StringComparison.OrdinalIgnoreCase))
|
||||
return "blake3-256";
|
||||
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha256";
|
||||
if (hash.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha512";
|
||||
return "blake3-256";
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Microservice.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Microservice SDK tests -->
|
||||
<!-- Disable Concelier test infrastructure since not needed for Microservice SDK tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Common.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Config.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Transport.InMemory.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for InMemory tests -->
|
||||
<!-- Disable Concelier test infrastructure since not needed for InMemory tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Transport.RabbitMq.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user