Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer for canonical (deterministic) ReachGraph JSON.
|
||||
/// Guarantees:
|
||||
/// - Lexicographically sorted object keys
|
||||
/// - Arrays sorted by deterministic field (Nodes by Id, Edges by From/To)
|
||||
/// - UTC ISO-8601 timestamps with millisecond precision
|
||||
/// - Null fields omitted
|
||||
/// - Minified output for storage, prettified for debugging
|
||||
/// </summary>
|
||||
public sealed class CanonicalReachGraphSerializer
|
||||
{
|
||||
private readonly JsonSerializerOptions _minifiedOptions;
|
||||
private readonly JsonSerializerOptions _prettyOptions;
|
||||
|
||||
public CanonicalReachGraphSerializer()
|
||||
{
|
||||
_minifiedOptions = CreateSerializerOptions(writeIndented: false);
|
||||
_prettyOptions = CreateSerializerOptions(writeIndented: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to canonical minified JSON bytes.
|
||||
/// </summary>
|
||||
public byte[] SerializeMinimal(ReachGraphMinimal graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var canonical = Canonicalize(graph);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(canonical, _minifiedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to canonical prettified JSON for debugging.
|
||||
/// </summary>
|
||||
public string SerializePretty(ReachGraphMinimal graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var canonical = Canonicalize(graph);
|
||||
return JsonSerializer.Serialize(canonical, _prettyOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON bytes.
|
||||
/// </summary>
|
||||
public ReachGraphMinimal Deserialize(ReadOnlySpan<byte> json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
|
||||
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
|
||||
|
||||
return FromDto(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON string.
|
||||
/// </summary>
|
||||
public ReachGraphMinimal Deserialize(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(json);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
|
||||
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
|
||||
|
||||
return FromDto(dto);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
|
||||
{
|
||||
return new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
new Iso8601MillisecondConverter()
|
||||
},
|
||||
// Ensure consistent ordering
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a canonical DTO with sorted arrays.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
|
||||
{
|
||||
// Sort nodes by Id lexicographically
|
||||
var sortedNodes = graph.Nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.Select(n => new ReachGraphNodeDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort edges by From, then To
|
||||
var sortedEdges = graph.Edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.Select(e => new ReachGraphEdgeDto
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanationDto
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
|
||||
: null
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort signatures by KeyId if present
|
||||
List<ReachGraphSignatureDto>? sortedSignatures = null;
|
||||
if (graph.Signatures is { Length: > 0 } sigs)
|
||||
{
|
||||
sortedSignatures = sigs
|
||||
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
|
||||
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Sort entrypoints and selectors
|
||||
var sortedEntrypoints = graph.Scope.Entrypoints
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sortedSelectors = graph.Scope.Selectors
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
List<string>? sortedCves = graph.Scope.Cves is { Length: > 0 } cves
|
||||
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
// Sort env array
|
||||
var sortedEnv = graph.Artifact.Env
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Sort intoto array if present
|
||||
List<string>? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto
|
||||
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
return new ReachGraphMinimalDto
|
||||
{
|
||||
SchemaVersion = graph.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifactDto
|
||||
{
|
||||
Name = graph.Artifact.Name,
|
||||
Digest = graph.Artifact.Digest,
|
||||
Env = sortedEnv
|
||||
},
|
||||
Scope = new ReachGraphScopeDto
|
||||
{
|
||||
Entrypoints = sortedEntrypoints,
|
||||
Selectors = sortedSelectors,
|
||||
Cves = sortedCves
|
||||
},
|
||||
Nodes = sortedNodes,
|
||||
Edges = sortedEdges,
|
||||
Provenance = new ReachGraphProvenanceDto
|
||||
{
|
||||
Intoto = sortedIntoto,
|
||||
Inputs = new ReachGraphInputsDto
|
||||
{
|
||||
Sbom = graph.Provenance.Inputs.Sbom,
|
||||
Vex = graph.Provenance.Inputs.Vex,
|
||||
Callgraph = graph.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = graph.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = graph.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzerDto
|
||||
{
|
||||
Name = graph.Provenance.Analyzer.Name,
|
||||
Version = graph.Provenance.Analyzer.Version,
|
||||
ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest
|
||||
}
|
||||
},
|
||||
Signatures = sortedSignatures
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert DTO back to domain record.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
|
||||
{
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
SchemaVersion = dto.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifact(
|
||||
dto.Artifact.Name,
|
||||
dto.Artifact.Digest,
|
||||
[.. dto.Artifact.Env]
|
||||
),
|
||||
Scope = new ReachGraphScope(
|
||||
[.. dto.Scope.Entrypoints],
|
||||
[.. dto.Scope.Selectors],
|
||||
dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null
|
||||
),
|
||||
Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})],
|
||||
Edges = [.. dto.Edges.Select(e => new ReachGraphEdge
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? e.Why.Metadata.ToImmutableDictionary()
|
||||
: null
|
||||
}
|
||||
})],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null,
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = dto.Provenance.Inputs.Sbom,
|
||||
Vex = dto.Provenance.Inputs.Vex,
|
||||
Callgraph = dto.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = dto.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = dto.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzer(
|
||||
dto.Provenance.Analyzer.Name,
|
||||
dto.Provenance.Analyzer.Version,
|
||||
dto.Provenance.Analyzer.ToolchainDigest
|
||||
)
|
||||
},
|
||||
Signatures = dto.Signatures is { Count: > 0 }
|
||||
? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))]
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs for canonical serialization (alphabetically ordered properties)
|
||||
|
||||
private sealed class ReachGraphMinimalDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphArtifactDto Artifact { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required ReachGraphScopeDto Scope { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required List<ReachGraphNodeDto> Nodes { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public required List<ReachGraphEdgeDto> Edges { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public required ReachGraphProvenanceDto Provenance { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public List<ReachGraphSignatureDto>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphArtifactDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required List<string> Env { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphScopeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required List<string> Entrypoints { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required List<string> Selectors { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public List<string>? Cves { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphNodeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphNodeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public string? ModuleHash { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public string? Addr { get; init; }
|
||||
|
||||
[JsonPropertyOrder(8)]
|
||||
public bool? IsEntrypoint { get; init; }
|
||||
|
||||
[JsonPropertyOrder(9)]
|
||||
public bool? IsSink { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphEdgeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required EdgeExplanationDto Why { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EdgeExplanationDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required EdgeExplanationType Type { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Loc { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Guard { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public SortedDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphProvenanceDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public List<string>? Intoto { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphInputsDto Inputs { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required ReachGraphAnalyzerDto Analyzer { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphInputsDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Sbom { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Vex { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Callgraph { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? RuntimeFacts { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public string? Policy { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphAnalyzerDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string ToolchainDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphSignatureDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for ISO-8601 timestamps with millisecond precision.
|
||||
/// </summary>
|
||||
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
|
||||
return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
{
|
||||
// Always output in UTC with millisecond precision
|
||||
writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user