audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -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)

View File

@@ -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")
};
}

View File

@@ -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}'")
{

View File

@@ -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
};
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()));
}
}

View File

@@ -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.
}
}

View File

@@ -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,
};

View File

@@ -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))
{

View File

@@ -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));
}
}

View File

@@ -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 (&lt;0.5), unknown (null).</param>
/// <param name="Tier">Confidence tier: high (>=0.9), medium (>=0.7), low (>=0.5), very-low (&lt;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(

View File

@@ -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();
}
}

View File

@@ -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),

View File

@@ -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));
}

View File

@@ -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)
{

View File

@@ -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");