audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -145,7 +146,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
|
||||
{
|
||||
Entries = entries.OrderBy(e => e.BundleId).ToImmutableArray(),
|
||||
TotalCount = entries.Count,
|
||||
SourceIds = sourceIds.ToImmutableArray(),
|
||||
SourceIds = sourceIds.OrderBy(static id => id, StringComparer.Ordinal).ToImmutableArray(),
|
||||
ComputedAt = now,
|
||||
ETag = etag
|
||||
});
|
||||
@@ -208,7 +209,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
|
||||
string? nextCursor = null;
|
||||
if (offset + pageSize < catalog.TotalCount)
|
||||
{
|
||||
nextCursor = (offset + pageSize).ToString();
|
||||
nextCursor = (offset + pageSize).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return catalog with
|
||||
@@ -225,7 +226,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
|
||||
return 0;
|
||||
}
|
||||
|
||||
return int.TryParse(cursor, out var offset) ? offset : 0;
|
||||
return int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) ? offset : 0;
|
||||
}
|
||||
|
||||
private static string ComputeETag(IEnumerable<BundleCatalogEntry> entries)
|
||||
|
||||
@@ -89,20 +89,20 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_sources.TryGetValue(sourceId, out var source))
|
||||
{
|
||||
return Task.FromResult(BundleSourceValidationResult.Failure(sourceId, $"Source '{sourceId}' not found"));
|
||||
return Task.FromResult(BundleSourceValidationResult.Failure(sourceId, now, $"Source '{sourceId}' not found"));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Basic validation - actual implementation would check source accessibility
|
||||
var result = source.Type switch
|
||||
{
|
||||
"directory" => ValidateDirectorySource(source),
|
||||
"archive" => ValidateArchiveSource(source),
|
||||
"remote" => ValidateRemoteSource(source),
|
||||
_ => BundleSourceValidationResult.Failure(sourceId, $"Unknown source type: {source.Type}")
|
||||
"directory" => ValidateDirectorySource(source, now),
|
||||
"archive" => ValidateArchiveSource(source, now),
|
||||
"remote" => ValidateRemoteSource(source, now),
|
||||
_ => BundleSourceValidationResult.Failure(sourceId, now, $"Unknown source type: {source.Type}")
|
||||
};
|
||||
|
||||
// Update source status
|
||||
@@ -143,33 +143,33 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private BundleSourceValidationResult ValidateDirectorySource(BundleSourceInfo source)
|
||||
private static BundleSourceValidationResult ValidateDirectorySource(BundleSourceInfo source, DateTimeOffset validatedAt)
|
||||
{
|
||||
if (!Directory.Exists(source.Location))
|
||||
{
|
||||
return BundleSourceValidationResult.Failure(source.Id, $"Directory not found: {source.Location}");
|
||||
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Directory not found: {source.Location}");
|
||||
}
|
||||
|
||||
var bundleFiles = Directory.GetFiles(source.Location, "*.bundle.json", SearchOption.AllDirectories);
|
||||
return BundleSourceValidationResult.Success(source.Id, bundleFiles.Length);
|
||||
return BundleSourceValidationResult.Success(source.Id, bundleFiles.Length, validatedAt);
|
||||
}
|
||||
|
||||
private BundleSourceValidationResult ValidateArchiveSource(BundleSourceInfo source)
|
||||
private static BundleSourceValidationResult ValidateArchiveSource(BundleSourceInfo source, DateTimeOffset validatedAt)
|
||||
{
|
||||
if (!File.Exists(source.Location))
|
||||
{
|
||||
return BundleSourceValidationResult.Failure(source.Id, $"Archive not found: {source.Location}");
|
||||
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Archive not found: {source.Location}");
|
||||
}
|
||||
|
||||
// Actual implementation would inspect archive contents
|
||||
return BundleSourceValidationResult.Success(source.Id, 0);
|
||||
return BundleSourceValidationResult.Success(source.Id, 0, validatedAt);
|
||||
}
|
||||
|
||||
private BundleSourceValidationResult ValidateRemoteSource(BundleSourceInfo source)
|
||||
private BundleSourceValidationResult ValidateRemoteSource(BundleSourceInfo source, DateTimeOffset validatedAt)
|
||||
{
|
||||
if (!Uri.TryCreate(source.Location, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return BundleSourceValidationResult.Failure(source.Id, $"Invalid URL: {source.Location}");
|
||||
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Invalid URL: {source.Location}");
|
||||
}
|
||||
|
||||
// Actual implementation would check remote accessibility
|
||||
@@ -178,7 +178,7 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
|
||||
SourceId = source.Id,
|
||||
IsValid = true,
|
||||
Status = BundleSourceStatus.Unknown,
|
||||
ValidatedAt = _timeProvider.GetUtcNow(),
|
||||
ValidatedAt = validatedAt,
|
||||
Warnings = ImmutableArray.Create("Remote validation not implemented - assuming valid")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +43,6 @@ public interface ISealedModeEnforcer
|
||||
/// </summary>
|
||||
public sealed class SealedModeViolationException : Exception
|
||||
{
|
||||
public SealedModeViolationException(string sourceName, Uri destination)
|
||||
: this(sourceName, destination, DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
public SealedModeViolationException(string sourceName, Uri destination, DateTimeOffset occurredAt)
|
||||
: base($"Sealed mode violation: source '{sourceName}' attempted to access '{destination}'")
|
||||
{
|
||||
|
||||
@@ -46,24 +46,24 @@ public sealed record BundleSourceValidationResult
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static BundleSourceValidationResult Success(string sourceId, int bundleCount) => new()
|
||||
public static BundleSourceValidationResult Success(string sourceId, int bundleCount, DateTimeOffset validatedAt) => new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
IsValid = true,
|
||||
Status = BundleSourceStatus.Healthy,
|
||||
BundleCount = bundleCount,
|
||||
ValidatedAt = DateTimeOffset.UtcNow
|
||||
ValidatedAt = validatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static BundleSourceValidationResult Failure(string sourceId, params string[] errors) => new()
|
||||
public static BundleSourceValidationResult Failure(string sourceId, DateTimeOffset validatedAt, params string[] errors) => new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
IsValid = false,
|
||||
Status = BundleSourceStatus.Error,
|
||||
Errors = errors.ToImmutableArray(),
|
||||
ValidatedAt = DateTimeOffset.UtcNow
|
||||
ValidatedAt = validatedAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
private readonly IMergeHashCalculator _mergeHashCalculator;
|
||||
private readonly ISourceEdgeSigner? _signer;
|
||||
private readonly ILogger<CanonicalAdvisoryService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Source precedence ranks (lower = higher priority).
|
||||
@@ -42,11 +43,13 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
ICanonicalAdvisoryStore store,
|
||||
IMergeHashCalculator mergeHashCalculator,
|
||||
ILogger<CanonicalAdvisoryService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
ISourceEdgeSigner? signer = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_mergeHashCalculator = mergeHashCalculator ?? throw new ArgumentNullException(nameof(mergeHashCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_signer = signer; // Optional - if not provided, source edges are stored unsigned
|
||||
}
|
||||
|
||||
@@ -170,6 +173,9 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
|
||||
// 8. Create source edge
|
||||
var precedenceRank = GetPrecedenceRank(source);
|
||||
var fetchedAt = rawAdvisory.FetchedAt == default
|
||||
? _timeProvider.GetUtcNow()
|
||||
: rawAdvisory.FetchedAt;
|
||||
var addEdgeRequest = new AddSourceEdgeRequest
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
@@ -180,7 +186,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
PrecedenceRank = precedenceRank,
|
||||
DsseEnvelopeJson = dsseEnvelopeJson,
|
||||
RawPayloadJson = rawAdvisory.RawPayloadJson,
|
||||
FetchedAt = rawAdvisory.FetchedAt
|
||||
FetchedAt = fetchedAt
|
||||
};
|
||||
|
||||
var edgeResult = await _store.AddSourceEdgeAsync(addEdgeRequest, ct).ConfigureAwait(false);
|
||||
@@ -295,8 +301,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DegradeToStubsAsync(double scoreThreshold, CancellationToken ct = default)
|
||||
{
|
||||
// TODO: Implement stub degradation based on EPSS score or other criteria
|
||||
// This would query for low-interest canonicals and update their status to Stub
|
||||
// Not implemented: stub degradation requires a scoring policy and query pipeline.
|
||||
_logger.LogWarning(
|
||||
"DegradeToStubsAsync not yet implemented (threshold={Threshold})",
|
||||
scoreThreshold);
|
||||
|
||||
@@ -119,7 +119,7 @@ public sealed record RawAdvisory
|
||||
public string? RawPayloadJson { get; init; }
|
||||
|
||||
/// <summary>When the advisory was fetched.</summary>
|
||||
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -131,7 +131,7 @@ public sealed record AddSourceEdgeRequest
|
||||
public int PrecedenceRank { get; init; } = 100;
|
||||
public string? DsseEnvelopeJson { get; init; }
|
||||
public string? RawPayloadJson { get; init; }
|
||||
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -267,7 +267,10 @@ public sealed class CanonicalMerger
|
||||
}
|
||||
}
|
||||
|
||||
var credits = map.Values.Select(static s => s.Credit).ToImmutableArray();
|
||||
var credits = map
|
||||
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static item => item.Value.Credit)
|
||||
.ToImmutableArray();
|
||||
FieldDecision? decision = null;
|
||||
|
||||
if (considered.Count > 0)
|
||||
@@ -333,7 +336,10 @@ public sealed class CanonicalMerger
|
||||
}
|
||||
}
|
||||
|
||||
var references = map.Values.Select(static s => s.Reference).ToImmutableArray();
|
||||
var references = map
|
||||
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static item => item.Value.Reference)
|
||||
.ToImmutableArray();
|
||||
FieldDecision? decision = null;
|
||||
|
||||
if (considered.Count > 0)
|
||||
@@ -370,12 +376,12 @@ public sealed class CanonicalMerger
|
||||
additionalProvenance.Add(enriched.MergeProvenance);
|
||||
map[key] = new PackageSelection(enriched.Package, candidate.Source, candidate.Modified);
|
||||
|
||||
decisions.Add(new FieldDecision(
|
||||
Field: $"affectedPackages[{key}]",
|
||||
SelectedSource: candidate.Source,
|
||||
DecisionReason: "precedence",
|
||||
SelectedModified: candidate.Modified,
|
||||
ConsideredSources: consideredSources.ToImmutableArray()));
|
||||
decisions.Add(new FieldDecision(
|
||||
Field: $"affectedPackages[{key}]",
|
||||
SelectedSource: candidate.Source,
|
||||
DecisionReason: "precedence",
|
||||
SelectedModified: candidate.Modified,
|
||||
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -398,11 +404,14 @@ public sealed class CanonicalMerger
|
||||
SelectedSource: candidate.Source,
|
||||
DecisionReason: reason,
|
||||
SelectedModified: candidate.Modified,
|
||||
ConsideredSources: consideredSources.ToImmutableArray()));
|
||||
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
|
||||
}
|
||||
}
|
||||
|
||||
var packages = map.Values.Select(static s => s.Package).ToImmutableArray();
|
||||
var packages = map
|
||||
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static item => item.Value.Package)
|
||||
.ToImmutableArray();
|
||||
return new PackagesMergeResult(packages, decisions, additionalProvenance);
|
||||
}
|
||||
|
||||
@@ -435,7 +444,7 @@ public sealed class CanonicalMerger
|
||||
SelectedSource: candidate.Source,
|
||||
DecisionReason: "precedence",
|
||||
SelectedModified: candidate.Modified,
|
||||
ConsideredSources: consideredSources.ToImmutableArray()));
|
||||
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -471,7 +480,7 @@ public sealed class CanonicalMerger
|
||||
SelectedSource: candidate.Source,
|
||||
DecisionReason: decisionReason,
|
||||
SelectedModified: candidate.Modified,
|
||||
ConsideredSources: consideredSources.ToImmutableArray()));
|
||||
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class AdvisoryDsseMetadataResolver
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Same as above – fall through to remaining provenance entries.
|
||||
// Same as above - fall through to remaining provenance entries.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -21,7 +20,6 @@ public sealed class AdvisoryEventLog : IAdvisoryEventLog
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false,
|
||||
};
|
||||
|
||||
@@ -150,8 +150,8 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
var capturedLease = lease ?? throw new InvalidOperationException("Lease acquisition returned null.");
|
||||
try
|
||||
{
|
||||
_ = Task.Run(() => ExecuteJobAsync(definition, capturedLease, started, parameterSnapshot, trigger, linkedTokenSource), CancellationToken.None)
|
||||
.ContinueWith(t =>
|
||||
var executionTask = ExecuteJobAsync(definition, capturedLease, started, parameterSnapshot, trigger, linkedTokenSource);
|
||||
_ = executionTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.Exception is not null)
|
||||
{
|
||||
@@ -188,7 +188,7 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
// Release handled by background execution path. If we failed before scheduling, release here.
|
||||
if (lease is not null)
|
||||
{
|
||||
var releaseError = await TryReleaseLeaseAsync(lease, definition.Kind).ConfigureAwait(false);
|
||||
var releaseError = await TryReleaseLeaseAsync(lease, definition.Kind, cancellationToken).ConfigureAwait(false);
|
||||
if (releaseError is not null)
|
||||
{
|
||||
_logger.LogError(releaseError, "Failed to release lease {LeaseKey} for job {Kind}", lease.Key, definition.Kind);
|
||||
@@ -401,11 +401,11 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Exception?> TryReleaseLeaseAsync(JobLease lease, string kind)
|
||||
private async Task<Exception?> TryReleaseLeaseAsync(JobLease lease, string kind, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _leaseStore.ReleaseAsync(lease.Key, _holderId, CancellationToken.None).ConfigureAwait(false);
|
||||
await _leaseStore.ReleaseAsync(lease.Key, _holderId, cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -494,7 +494,7 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
|
||||
leaseException = await ObserveLeaseTaskAsync(heartbeatTask).ConfigureAwait(false);
|
||||
|
||||
var releaseException = await TryReleaseLeaseAsync(lease, definition.Kind).ConfigureAwait(false);
|
||||
var releaseException = await TryReleaseLeaseAsync(lease, definition.Kind, cancellationToken).ConfigureAwait(false);
|
||||
leaseException = CombineLeaseExceptions(leaseException, releaseException);
|
||||
|
||||
if (leaseException is not null)
|
||||
@@ -510,7 +510,7 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
{
|
||||
error = string.IsNullOrWhiteSpace(error)
|
||||
? leaseMessage
|
||||
: $"{error}{Environment.NewLine}{leaseMessage}";
|
||||
: $"{error}\n{leaseMessage}";
|
||||
executionException = executionException is null
|
||||
? leaseException
|
||||
: new AggregateException(executionException, leaseException);
|
||||
@@ -518,7 +518,7 @@ public sealed class JobCoordinator : IJobCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
completedSnapshot = await CompleteRunAsync(run.RunId, finalStatus, error, CancellationToken.None).ConfigureAwait(false);
|
||||
completedSnapshot = await CompleteRunAsync(run.RunId, finalStatus, error, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -80,7 +80,7 @@ public sealed class AdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
|
||||
}
|
||||
|
||||
var ticksText = payload[..separator];
|
||||
if (!long.TryParse(ticksText, out var ticks))
|
||||
if (!long.TryParse(ticksText, NumberStyles.None, CultureInfo.InvariantCulture, out var ticks))
|
||||
{
|
||||
throw new FormatException("Cursor timestamp invalid.");
|
||||
}
|
||||
@@ -105,7 +105,7 @@ public sealed class AdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
|
||||
|
||||
private static string? EncodeCursor(AdvisoryLinkset linkset)
|
||||
{
|
||||
var payload = $"{linkset.CreatedAt.UtcTicks}:{linkset.AdvisoryId}";
|
||||
var payload = $"{linkset.CreatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}:{linkset.AdvisoryId}";
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
@@ -67,7 +68,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
Conflicts: conflicts,
|
||||
Provenance: provenance,
|
||||
CreatedAt: linkset.CreatedAt,
|
||||
ReplayCursor: replayCursor ?? linkset.CreatedAt.ToUniversalTime().Ticks.ToString(),
|
||||
ReplayCursor: replayCursor ?? linkset.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture),
|
||||
BuiltByJobId: linkset.BuiltByJobId,
|
||||
TraceId: traceId);
|
||||
}
|
||||
@@ -94,7 +95,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
sb.Append('|');
|
||||
sb.Append(linkset.Source);
|
||||
sb.Append('|');
|
||||
sb.Append(linkset.CreatedAt.ToUniversalTime().Ticks);
|
||||
sb.Append(linkset.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append('|');
|
||||
sb.Append(delta.Type);
|
||||
sb.Append('|');
|
||||
@@ -135,7 +136,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts namespace prefix from tenant ID (e.g., "org:acme" → "org").
|
||||
/// Extracts namespace prefix from tenant ID (e.g., "org:acme" -> "org").
|
||||
/// </summary>
|
||||
private static string? ExtractNamespace(string tenantId)
|
||||
{
|
||||
@@ -243,13 +244,34 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
|
||||
private static bool ConflictsEqual(IReadOnlyList<AdvisoryLinksetConflict>? a, IReadOnlyList<AdvisoryLinksetConflict>? b)
|
||||
{
|
||||
if (a is null && b is null) return true;
|
||||
if (a is null || b is null) return false;
|
||||
if (a.Count != b.Count) return false;
|
||||
|
||||
for (var i = 0; i < a.Count; i++)
|
||||
if (a is null && b is null)
|
||||
{
|
||||
if (a[i].Field != b[i].Field || a[i].Reason != b[i].Reason)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a is null || b is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.Count != b.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var orderedA = a
|
||||
.OrderBy(c => c.Field, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Reason, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var orderedB = b
|
||||
.OrderBy(c => c.Field, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Reason, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
for (var i = 0; i < orderedA.Count; i++)
|
||||
{
|
||||
if (!string.Equals(orderedA[i].Field, orderedB[i].Field, StringComparison.Ordinal) ||
|
||||
!string.Equals(orderedA[i].Reason, orderedB[i].Reason, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -267,7 +289,10 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
}
|
||||
|
||||
return conflicts
|
||||
.Select(c => new AdvisoryLinksetConflictSummary(c.Field, c.Reason, c.SourceIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty))
|
||||
.Select(c => new AdvisoryLinksetConflictSummary(
|
||||
c.Field,
|
||||
c.Reason,
|
||||
SortValues(c.SourceIds)))
|
||||
.OrderBy(c => c.Field, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Reason, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
@@ -283,13 +308,27 @@ public sealed record AdvisoryLinksetUpdatedEvent(
|
||||
PolicyHash: null);
|
||||
}
|
||||
|
||||
var hashes = provenance.ObservationHashes?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
var hashes = SortValues(provenance.ObservationHashes);
|
||||
|
||||
return new AdvisoryLinksetProvenanceSummary(
|
||||
ObservationHashes: hashes,
|
||||
ToolVersion: provenance.ToolVersion,
|
||||
PolicyHash: provenance.PolicyHash);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> SortValues(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -332,7 +371,7 @@ public sealed record AdvisoryLinksetTenantMetadata(
|
||||
/// Per CONCELIER-POLICY-23-002.
|
||||
/// </summary>
|
||||
/// <param name="Value">Raw confidence score (0.0 - 1.0).</param>
|
||||
/// <param name="Tier">Confidence tier: high (≥0.9), medium (≥0.7), low (≥0.5), very-low (<0.5), unknown (null).</param>
|
||||
/// <param name="Tier">Confidence tier: high (>=0.9), medium (>=0.7), low (>=0.5), very-low (<0.5), unknown (null).</param>
|
||||
/// <param name="ConflictCount">Number of conflicts detected in the linkset.</param>
|
||||
/// <param name="Factors">Human-readable factors contributing to confidence score.</param>
|
||||
public sealed record AdvisoryLinksetConfidenceSummary(
|
||||
|
||||
@@ -90,7 +90,8 @@ internal static class LinksetCorrelation
|
||||
if (anyAliases)
|
||||
{
|
||||
var values = inputs
|
||||
.Select(i => $"{i.Vendor ?? "source"}:{i.Aliases.FirstOrDefault() ?? "<none>"}")
|
||||
.Select(i => $"{i.Vendor ?? "source"}:{FirstSortedOrDefault(i.Aliases)}")
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
conflicts.Add(new AdvisoryLinksetConflict("aliases", "alias-inconsistency", values));
|
||||
}
|
||||
@@ -151,10 +152,12 @@ internal static class LinksetCorrelation
|
||||
.SelectMany(i => i.Purls
|
||||
.Where(p => ExtractPackageKey(p) == package)
|
||||
.Select(p => $"{i.Vendor ?? "source"}:{p}"))
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var sourceIds = inputs
|
||||
.Select(i => i.Vendor ?? "source")
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (values.Length > 1)
|
||||
@@ -261,21 +264,23 @@ internal static class LinksetCorrelation
|
||||
|
||||
if (overlap == 0d && !string.Equals(inputList[i].Vendor, inputList[j].Vendor, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var firstExample = FirstSortedOrDefault(first);
|
||||
var secondExample = FirstSortedOrDefault(second);
|
||||
var values = new[]
|
||||
{
|
||||
$"{inputList[i].Vendor ?? "source"}:{first.FirstOrDefault() ?? "<none>"}",
|
||||
$"{inputList[j].Vendor ?? "source"}:{second.FirstOrDefault() ?? "<none>"}"
|
||||
$"{inputList[i].Vendor ?? "source"}:{firstExample}",
|
||||
$"{inputList[j].Vendor ?? "source"}:{secondExample}"
|
||||
};
|
||||
|
||||
conflicts.Add(new AdvisoryLinksetConflict(
|
||||
"references",
|
||||
"reference-clash",
|
||||
values,
|
||||
values.OrderBy(static value => value, StringComparer.Ordinal).ToArray(),
|
||||
new[]
|
||||
{
|
||||
inputList[i].Vendor ?? "source",
|
||||
inputList[j].Vendor ?? "source"
|
||||
}));
|
||||
}.OrderBy(static value => value, StringComparer.Ordinal).ToArray()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,18 +328,25 @@ internal static class LinksetCorrelation
|
||||
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
var key = $"{conflict.Field}|{conflict.Reason}|{string.Join('|', conflict.Values ?? Array.Empty<string>())}";
|
||||
var normalizedValues = NormalizeValues(conflict.Values);
|
||||
var normalizedSources = NormalizeValues(conflict.SourceIds);
|
||||
var key = $"{conflict.Field}|{conflict.Reason}|{string.Join('|', normalizedValues)}";
|
||||
if (set.Add(key))
|
||||
{
|
||||
if (conflict.SourceIds is null || conflict.SourceIds.Count == 0)
|
||||
if (normalizedSources.Count == 0)
|
||||
{
|
||||
var allSources = inputs.Select(i => i.Vendor ?? "source").Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
list.Add(conflict with { SourceIds = allSources });
|
||||
normalizedSources = inputs
|
||||
.Select(i => i.Vendor ?? "source")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
|
||||
list.Add(conflict with
|
||||
{
|
||||
list.Add(conflict);
|
||||
}
|
||||
Values = normalizedValues,
|
||||
SourceIds = normalizedSources
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,4 +358,28 @@ internal static class LinksetCorrelation
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) => Math.Clamp(value, 0d, 1d);
|
||||
|
||||
private static string FirstSortedOrDefault(IEnumerable<string> values)
|
||||
{
|
||||
var first = values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
return string.IsNullOrEmpty(first) ? "<none>" : first;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeValues(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
@@ -51,7 +52,7 @@ public sealed record AdvisoryObservationUpdatedEvent(
|
||||
DocumentSha: observation.Upstream.ContentHash,
|
||||
ObservationHash: observationHash,
|
||||
IngestedAt: observation.CreatedAt,
|
||||
ReplayCursor: replayCursor ?? observation.CreatedAt.ToUniversalTime().Ticks.ToString(),
|
||||
ReplayCursor: replayCursor ?? observation.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture),
|
||||
SupersedesId: supersedesId,
|
||||
TraceId: traceId);
|
||||
}
|
||||
@@ -76,11 +77,17 @@ public sealed record AdvisoryObservationUpdatedEvent(
|
||||
.OrderBy(static v => v, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var relationships = rawLinkset.Relationships.Select(static rel => new AdvisoryObservationRelationshipSummary(
|
||||
rel.Type,
|
||||
rel.Source,
|
||||
rel.Target,
|
||||
rel.Provenance)).ToImmutableArray();
|
||||
var relationships = rawLinkset.Relationships
|
||||
.OrderBy(static rel => rel.Type, StringComparer.Ordinal)
|
||||
.ThenBy(static rel => rel.Source, StringComparer.Ordinal)
|
||||
.ThenBy(static rel => rel.Target, StringComparer.Ordinal)
|
||||
.ThenBy(static rel => rel.Provenance ?? string.Empty, StringComparer.Ordinal)
|
||||
.Select(static rel => new AdvisoryObservationRelationshipSummary(
|
||||
rel.Type,
|
||||
rel.Source,
|
||||
rel.Target,
|
||||
rel.Provenance))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AdvisoryObservationLinksetSummary(
|
||||
Aliases: SortSet(linkset.Aliases),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -313,8 +314,8 @@ public sealed class AdvisoryFieldChangeEmitter : IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "cvss_score",
|
||||
PreviousValue: previousScore.Value.ToString("F1"),
|
||||
CurrentValue: currentScore.Value.ToString("F1"),
|
||||
PreviousValue: previousScore.Value.ToString("F1", CultureInfo.InvariantCulture),
|
||||
CurrentValue: currentScore.Value.ToString("F1", CultureInfo.InvariantCulture),
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class LinkNotMergeTenantCapabilitiesProvider : ITenantCapabilities
|
||||
public TenantCapabilitiesResponse GetCapabilities(TenantScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
scope.Validate();
|
||||
scope.Validate(_timeProvider);
|
||||
|
||||
// In Link-Not-Merge mode, merge is never allowed
|
||||
// This enforces the contract even if the token claims mergeAllowed=true
|
||||
@@ -89,7 +89,7 @@ public sealed class LinkNotMergeTenantCapabilitiesProvider : ITenantCapabilities
|
||||
public void ValidateScope(TenantScope scope, params string[] requiredScopes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
scope.Validate();
|
||||
scope.Validate(_timeProvider);
|
||||
|
||||
if (requiredScopes.Length == 0)
|
||||
{
|
||||
|
||||
@@ -19,11 +19,13 @@ public sealed record TenantScope(
|
||||
/// <summary>
|
||||
/// Validates that the tenant scope is well-formed.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider used for expiry checks.</param>
|
||||
/// <param name="asOf">The time to check expiry against. Defaults to current UTC time.</param>
|
||||
public void Validate(DateTimeOffset? asOf = null)
|
||||
public void Validate(TimeProvider timeProvider, DateTimeOffset? asOf = null)
|
||||
{
|
||||
var now = asOf ?? DateTimeOffset.UtcNow;
|
||||
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
var now = asOf ?? timeProvider.GetUtcNow();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
throw new TenantScopeException("auth/tenant-scope-missing", "TenantId is required");
|
||||
|
||||
Reference in New Issue
Block a user