save progress
This commit is contained in:
@@ -4,15 +4,21 @@ Deterministic replay token generation used to make triage decisions and scoring
|
||||
|
||||
## Token format
|
||||
|
||||
`replay:v<version>:<algorithm>:<sha256_hex>`
|
||||
v1 (no expiration):
|
||||
|
||||
`replay:v1.0:<algorithm>:<sha256_hex>`
|
||||
|
||||
Example:
|
||||
|
||||
`replay:v1.0:SHA-256:0123abcd...`
|
||||
|
||||
v2 (includes expiration):
|
||||
|
||||
`replay:v2.0:<algorithm>:<sha256_hex>:<expires_unix_seconds>`
|
||||
|
||||
## Usage
|
||||
|
||||
- Create a `ReplayTokenRequest` with feed/rules/policy/input digests.
|
||||
- Call `IReplayTokenGenerator.Generate(request)` to get a stable token value.
|
||||
- Store the token’s `Canonical` string alongside immutable decision events.
|
||||
|
||||
- Store the token's `Canonical` string alongside immutable decision events.
|
||||
- `ReplayToken.Parse` uses `DateTimeOffset.UnixEpoch` for `GeneratedAt` because the canonical format does not include generation time.
|
||||
|
||||
@@ -22,21 +22,25 @@ public sealed class ReplayCliSnippetGenerator
|
||||
"stellaops",
|
||||
"replay",
|
||||
"decision",
|
||||
$"--token {token.Value}",
|
||||
$"--alert-id {alertId}"
|
||||
"--token",
|
||||
QuoteArgument(token.Value),
|
||||
"--alert-id",
|
||||
QuoteArgument(alertId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedManifestUri))
|
||||
{
|
||||
parts.Add($"--feed-manifest {feedManifestUri.Trim()}");
|
||||
parts.Add("--feed-manifest");
|
||||
parts.Add(QuoteArgument(feedManifestUri.Trim()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyVersion))
|
||||
{
|
||||
parts.Add($"--policy-version {policyVersion.Trim()}");
|
||||
parts.Add("--policy-version");
|
||||
parts.Add(QuoteArgument(policyVersion.Trim()));
|
||||
}
|
||||
|
||||
return string.Join(" \\\n+ ", parts);
|
||||
return string.Join(" \\\n ", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,15 +59,28 @@ public sealed class ReplayCliSnippetGenerator
|
||||
"stellaops",
|
||||
"replay",
|
||||
"scoring",
|
||||
$"--token {token.Value}",
|
||||
$"--subject {subjectKey}"
|
||||
"--token",
|
||||
QuoteArgument(token.Value),
|
||||
"--subject",
|
||||
QuoteArgument(subjectKey)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configVersion))
|
||||
{
|
||||
parts.Add($"--config-version {configVersion.Trim()}");
|
||||
parts.Add("--config-version");
|
||||
parts.Add(QuoteArgument(configVersion.Trim()));
|
||||
}
|
||||
|
||||
return string.Join(" \\\n+ ", parts);
|
||||
return string.Join(" \\\n ", parts);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "''";
|
||||
}
|
||||
|
||||
return $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
|
||||
/// <summary>
|
||||
/// Parse a canonical token string.
|
||||
/// Supports both v1.0 format (4 parts) and v2.0 format with expiration (5 parts).
|
||||
/// GeneratedAt is set to UnixEpoch because the canonical format does not include it.
|
||||
/// </summary>
|
||||
public static ReplayToken Parse(string canonical)
|
||||
{
|
||||
|
||||
@@ -29,8 +29,7 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var canonical = Canonicalize(request);
|
||||
var hashHex = ComputeHash(canonical);
|
||||
var hashHex = ComputeTokenValue(request, ReplayToken.DefaultVersion);
|
||||
|
||||
return new ReplayToken(hashHex, _timeProvider.GetUtcNow());
|
||||
}
|
||||
@@ -39,11 +38,16 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var canonical = Canonicalize(request);
|
||||
var hashHex = ComputeHash(canonical);
|
||||
var effectiveExpiration = expiration ?? ReplayToken.DefaultExpiration;
|
||||
if (effectiveExpiration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expiration), "Expiration must be positive.");
|
||||
}
|
||||
|
||||
var hashHex = ComputeTokenValue(request, ReplayToken.VersionWithExpiration);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now + (expiration ?? ReplayToken.DefaultExpiration);
|
||||
var expiresAt = now + effectiveExpiration;
|
||||
|
||||
return new ReplayToken(hashHex, now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
}
|
||||
@@ -53,8 +57,8 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var computed = Generate(request);
|
||||
return string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase);
|
||||
var computed = ComputeTokenValue(request, token.Version);
|
||||
return string.Equals(token.Value, computed, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ReplayTokenVerificationResult VerifyWithExpiration(ReplayToken token, ReplayTokenRequest request)
|
||||
@@ -63,8 +67,8 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Check hash first
|
||||
var computed = Generate(request);
|
||||
if (!string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase))
|
||||
var computed = ComputeTokenValue(request, token.Version);
|
||||
if (!string.Equals(token.Value, computed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReplayTokenVerificationResult.Invalid;
|
||||
}
|
||||
@@ -84,6 +88,12 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
return _cryptoHash.ComputeHashHex(bytes, HashAlgorithms.Sha256);
|
||||
}
|
||||
|
||||
private string ComputeTokenValue(ReplayTokenRequest request, string version)
|
||||
{
|
||||
var canonical = Canonicalize(request, version);
|
||||
return ComputeHash(canonical);
|
||||
}
|
||||
|
||||
private static string? NormalizeValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -117,23 +127,40 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var normalized = values
|
||||
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key))
|
||||
.Select(static kvp => new KeyValuePair<string, string>(kvp.Key.Trim(), kvp.Value?.Trim() ?? string.Empty))
|
||||
var normalized = new List<KeyValuePair<string, string>>(values.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = kvp.Key.Trim();
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
throw new ArgumentException($"AdditionalContext contains duplicate key after normalization: '{key}'.", nameof(values));
|
||||
}
|
||||
|
||||
normalized.Add(new KeyValuePair<string, string>(key, kvp.Value?.Trim() ?? string.Empty));
|
||||
}
|
||||
|
||||
var ordered = normalized
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal);
|
||||
|
||||
return normalized;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic canonical representation of inputs.
|
||||
/// </summary>
|
||||
private static string Canonicalize(ReplayTokenRequest request)
|
||||
private static string Canonicalize(ReplayTokenRequest request, string version)
|
||||
{
|
||||
var canonical = new CanonicalReplayInput
|
||||
{
|
||||
Version = ReplayToken.DefaultVersion,
|
||||
Version = version,
|
||||
FeedManifests = NormalizeSortedList(request.FeedManifests),
|
||||
RulesVersion = NormalizeValue(request.RulesVersion),
|
||||
RulesHash = NormalizeValue(request.RulesHash),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Audit.ReplayToken</RootNamespace>
|
||||
<Description>Deterministic replay token generation for audit and reproducibility</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0073-M | DONE | Maintainability audit for StellaOps.Audit.ReplayToken. |
|
||||
| AUDIT-0073-T | DONE | Test coverage audit for StellaOps.Audit.ReplayToken. |
|
||||
| AUDIT-0073-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0073-A | DONE | Applied library changes + coverage updates. |
|
||||
|
||||
Reference in New Issue
Block a user