consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.GroundTruth.Abstractions\StellaOps.BinaryIndex.GroundTruth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Normalization\StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj" />
|
||||
<ProjectReference Include="..\..\..\Symbols\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.BinaryIndex.FixIndex/StellaOps.BinaryIndex.FixIndex.csproj" />
|
||||
<ProjectReference Include="../../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
using StellaOps.Symbols.Bundle.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Bundle.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// SYMS-BUNDLE-401-014: Builds deterministic symbol bundles for air-gapped installations.
|
||||
/// </summary>
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a symbol bundle from the specified options.
|
||||
/// </summary>
|
||||
Task<BundleBuildResult> BuildAsync(
|
||||
BundleBuildOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a bundle's integrity and signatures.
|
||||
/// </summary>
|
||||
Task<BundleVerifyResult> VerifyAsync(
|
||||
string bundlePath,
|
||||
BundleVerifyOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a bundle to target directory.
|
||||
/// </summary>
|
||||
Task<BundleExtractResult> ExtractAsync(
|
||||
string bundlePath,
|
||||
string outputDir,
|
||||
BundleExtractOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists contents of a bundle without extracting.
|
||||
/// </summary>
|
||||
Task<BundleManifest?> InspectAsync(
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for building a symbol bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleBuildOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version (SemVer).
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source directory containing symbol manifests and blobs.
|
||||
/// </summary>
|
||||
public required string SourceDir { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for the bundle archive.
|
||||
/// </summary>
|
||||
public required string OutputDir { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform filter (e.g., "linux-x64"). Null means all platforms.
|
||||
/// </summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID filter. Null means all tenants.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sign the bundle with DSSE.
|
||||
/// </summary>
|
||||
public bool Sign { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to signing key (PEM-encoded private key).
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for DSSE signature.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm to use.
|
||||
/// </summary>
|
||||
public string SigningAlgorithm { get; init; } = "ecdsa-p256";
|
||||
|
||||
/// <summary>
|
||||
/// Submit to Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool SubmitRekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL.
|
||||
/// </summary>
|
||||
public string RekorUrl { get; init; } = "https://rekor.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// Include Rekor log public key for offline verification.
|
||||
/// </summary>
|
||||
public bool IncludeRekorPublicKey { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include public key in manifest for offline verification.
|
||||
/// </summary>
|
||||
public bool IncludePublicKey { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle format (zip or tar.gz).
|
||||
/// </summary>
|
||||
public BundleFormat Format { get; init; } = BundleFormat.Zip;
|
||||
|
||||
/// <summary>
|
||||
/// Compression level (0-9).
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; init; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata to include in manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle size in bytes (0 = unlimited).
|
||||
/// </summary>
|
||||
public long MaxSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, create multiple bundles if size limit exceeded.
|
||||
/// </summary>
|
||||
public bool AllowSplit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle archive format.
|
||||
/// </summary>
|
||||
public enum BundleFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// ZIP archive format.
|
||||
/// </summary>
|
||||
Zip = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Gzipped TAR archive format.
|
||||
/// </summary>
|
||||
TarGz = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle build operation.
|
||||
/// </summary>
|
||||
public sealed record BundleBuildResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the build succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the created bundle archive.
|
||||
/// </summary>
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the manifest JSON file.
|
||||
/// </summary>
|
||||
public string? ManifestPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle manifest.
|
||||
/// </summary>
|
||||
public BundleManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if build failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Warnings during build.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Build duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for verifying a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to public key for signature verification.
|
||||
/// If null, uses embedded public key.
|
||||
/// </summary>
|
||||
public string? PublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verify Rekor inclusion proof offline.
|
||||
/// </summary>
|
||||
public bool VerifyRekorOffline { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to Rekor public key for offline verification.
|
||||
/// If null, uses embedded key.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verify all blob hashes.
|
||||
/// </summary>
|
||||
public bool VerifyBlobHashes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Verify manifest hashes.
|
||||
/// </summary>
|
||||
public bool VerifyManifestHashes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a valid DSSE signature for verification to pass.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Require a Rekor checkpoint and valid inclusion proof.
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall verification status.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public required SignatureStatus SignatureStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification status.
|
||||
/// </summary>
|
||||
public RekorVerifyStatus? RekorStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash verification status.
|
||||
/// </summary>
|
||||
public required HashVerifyStatus HashStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Verification warnings.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Verified manifest (if valid).
|
||||
/// </summary>
|
||||
public BundleManifest? Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public enum SignatureStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle is not signed.
|
||||
/// </summary>
|
||||
Unsigned = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Signature is valid.
|
||||
/// </summary>
|
||||
Valid = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification failed.
|
||||
/// </summary>
|
||||
Invalid = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Could not verify (missing key, etc.).
|
||||
/// </summary>
|
||||
Unknown = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification status.
|
||||
/// </summary>
|
||||
public enum RekorVerifyStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// No Rekor checkpoint present.
|
||||
/// </summary>
|
||||
NotPresent = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof verified offline.
|
||||
/// </summary>
|
||||
VerifiedOffline = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Verified against live Rekor.
|
||||
/// </summary>
|
||||
VerifiedOnline = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Verification failed.
|
||||
/// </summary>
|
||||
Invalid = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hash verification status.
|
||||
/// </summary>
|
||||
public sealed record HashVerifyStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle hash valid.
|
||||
/// </summary>
|
||||
public required bool BundleHashValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries with valid hashes.
|
||||
/// </summary>
|
||||
public required int ValidEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries with invalid hashes.
|
||||
/// </summary>
|
||||
public required int InvalidEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total entries checked.
|
||||
/// </summary>
|
||||
public required int TotalEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entries with hash mismatches.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> InvalidEntryIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for extracting a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleExtractOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify bundle before extracting.
|
||||
/// </summary>
|
||||
public bool VerifyFirst { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Verification options if VerifyFirst is true.
|
||||
/// </summary>
|
||||
public BundleVerifyOptions? VerifyOptions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform filter for extraction.
|
||||
/// </summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite existing files.
|
||||
/// </summary>
|
||||
public bool Overwrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract manifest files (not blobs).
|
||||
/// </summary>
|
||||
public bool ManifestsOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle extraction.
|
||||
/// </summary>
|
||||
public sealed record BundleExtractResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether extraction succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification result (if verification was performed).
|
||||
/// </summary>
|
||||
public BundleVerifyResult? VerifyResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries extracted.
|
||||
/// </summary>
|
||||
public int ExtractedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries skipped.
|
||||
/// </summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes extracted.
|
||||
/// </summary>
|
||||
public long TotalBytesExtracted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extraction duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Symbols.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// SYMS-BUNDLE-401-014: Symbol bundle manifest for air-gapped installations.
|
||||
/// Contains deterministic ordering of symbol entries with DSSE signatures
|
||||
/// and Rekor checkpoint references.
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for bundle manifest format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "stellaops.symbols.bundle/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Unique bundle identifier (BLAKE3 hash of canonical manifest content).
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable bundle name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version (SemVer).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle creation timestamp (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform/architecture filter for included symbols (e.g., "linux-x64").
|
||||
/// Null means all platforms.
|
||||
/// </summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation. Null means system-wide bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol entries included in this bundle (deterministically sorted).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entries")]
|
||||
public required IReadOnlyList<BundleEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of all blob data in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalSizeBytes")]
|
||||
public long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public BundleSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log checkpoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorCheckpoint")]
|
||||
public RekorCheckpoint? RekorCheckpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for all hashes in this manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashAlgorithm")]
|
||||
public string HashAlgorithm { get; init; } = "blake3";
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for offline verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual entry in a symbol bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug ID for symbol lookup.
|
||||
/// </summary>
|
||||
[JsonPropertyName("debugId")]
|
||||
public required string DebugId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Code ID (GNU build-id, PE checksum) if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("codeId")]
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original binary name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("binaryName")]
|
||||
public required string BinaryName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform/architecture (e.g., linux-x64, win-x64).
|
||||
/// </summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format (ELF, PE, Mach-O, WASM).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the manifest content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestHash")]
|
||||
public required string ManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the symbol blob content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blobHash")]
|
||||
public required string BlobHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the blob in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blobSizeBytes")]
|
||||
public long BlobSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path within the bundle archive.
|
||||
/// Format: "symbols/{debugId}/{binaryName}.symbols"
|
||||
/// </summary>
|
||||
[JsonPropertyName("archivePath")]
|
||||
public required string ArchivePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of symbols in the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolCount")]
|
||||
public int SymbolCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope digest for individual manifest signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsseDigest")]
|
||||
public string? DsseDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if individually published.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information for the bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the bundle is signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "ecdsa-p256", "ed25519", "rsa-pss-sha256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsseDigest")]
|
||||
public string? DsseDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing timestamp (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain for verification (PEM-encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
public IReadOnlyList<string>? CertificateChain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public key for offline verification (PEM-encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type used during signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string? PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded canonical payload bytes that were signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public string? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature over DSSE pre-authenticated encoding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log checkpoint for offline verification.
|
||||
/// </summary>
|
||||
public sealed record RekorCheckpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor server URL where this checkpoint was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorUrl")]
|
||||
public required string RekorUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log entry ID (UUID or log index).
|
||||
/// </summary>
|
||||
[JsonPropertyName("logEntryId")]
|
||||
public required string LogEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index (monotonic sequence number).
|
||||
/// </summary>
|
||||
[JsonPropertyName("logIndex")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed entry timestamp from Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integratedTime")]
|
||||
public required DateTimeOffset IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("treeSize")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof for offline verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public InclusionProof? InclusionProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed checkpoint from the log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedCheckpoint")]
|
||||
public string? SignedCheckpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public key of the Rekor log for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("logPublicKey")]
|
||||
public string? LogPublicKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree inclusion proof for offline verification.
|
||||
/// </summary>
|
||||
public sealed record InclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Log index of the entry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("logIndex")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of proof.
|
||||
/// </summary>
|
||||
[JsonPropertyName("treeSize")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hashes forming the Merkle proof path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashes")]
|
||||
public required IReadOnlyList<string> Hashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public string? Checkpoint { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Symbols.Bundle.Abstractions;
|
||||
|
||||
namespace StellaOps.Symbols.Bundle;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Symbol Bundle services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds symbol bundle services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolBundle(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IBundleBuilder, BundleBuilder>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>StellaOps Symbol Bundle - Deterministic symbol bundles for air-gapped installs with DSSE manifests and Rekor checkpoints</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Symbols.Bundle Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Symbols/StellaOps.Symbols.Bundle/StellaOps.Symbols.Bundle.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,322 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// LRU disk cache for symbol data with size-based eviction.
|
||||
/// </summary>
|
||||
public sealed class DiskLruCache : IDisposable
|
||||
{
|
||||
private readonly string _cachePath;
|
||||
private readonly long _maxSizeBytes;
|
||||
private readonly ILogger<DiskLruCache>? _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _index = new();
|
||||
private readonly SemaphoreSlim _evictionLock = new(1, 1);
|
||||
private long _currentSizeBytes;
|
||||
private bool _disposed;
|
||||
|
||||
private const string IndexFileName = ".cache-index.json";
|
||||
|
||||
public DiskLruCache(string cachePath, long maxSizeBytes, ILogger<DiskLruCache>? logger = null)
|
||||
{
|
||||
_cachePath = cachePath ?? throw new ArgumentNullException(nameof(cachePath));
|
||||
_maxSizeBytes = maxSizeBytes > 0 ? maxSizeBytes : throw new ArgumentOutOfRangeException(nameof(maxSizeBytes));
|
||||
_logger = logger;
|
||||
|
||||
Directory.CreateDirectory(_cachePath);
|
||||
LoadIndex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached item by key.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
if (!_index.TryGetValue(hash, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(hash);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_index.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update access time (LRU tracking)
|
||||
entry.LastAccess = DateTimeOffset.UtcNow;
|
||||
_index[hash] = entry;
|
||||
|
||||
_logger?.LogDebug("Cache hit for key {Key}", key);
|
||||
return data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to read cached file for key {Key}", key);
|
||||
_index.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores an item in the cache.
|
||||
/// </summary>
|
||||
public async Task SetAsync(string key, byte[] data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (data.Length > _maxSizeBytes)
|
||||
{
|
||||
_logger?.LogWarning("Data size {Size} exceeds max cache size {MaxSize}, skipping cache", data.Length, _maxSizeBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
var filePath = GetFilePath(hash);
|
||||
|
||||
// Ensure enough space
|
||||
await EnsureSpaceAsync(data.Length, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(filePath, data, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entry = new CacheEntry
|
||||
{
|
||||
Key = key,
|
||||
Hash = hash,
|
||||
Size = data.Length,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastAccess = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (_index.TryGetValue(hash, out var existing))
|
||||
{
|
||||
Interlocked.Add(ref _currentSizeBytes, -existing.Size);
|
||||
}
|
||||
|
||||
_index[hash] = entry;
|
||||
Interlocked.Add(ref _currentSizeBytes, data.Length);
|
||||
|
||||
_logger?.LogDebug("Cached {Size} bytes for key {Key}", data.Length, key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to cache data for key {Key}", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the cache.
|
||||
/// </summary>
|
||||
public Task<bool> RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
if (!_index.TryRemove(hash, out var entry))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
Interlocked.Add(ref _currentSizeBytes, -entry.Size);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to remove cached file for key {Key}", key);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached items.
|
||||
/// </summary>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
foreach (var entry in _index.Values)
|
||||
{
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
_index.Clear();
|
||||
Interlocked.Exchange(ref _currentSizeBytes, 0);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current cache statistics.
|
||||
/// </summary>
|
||||
public CacheStats GetStats()
|
||||
{
|
||||
return new CacheStats
|
||||
{
|
||||
ItemCount = _index.Count,
|
||||
CurrentSizeBytes = Interlocked.Read(ref _currentSizeBytes),
|
||||
MaxSizeBytes = _maxSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnsureSpaceAsync(long requiredBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Read(ref _currentSizeBytes) + requiredBytes <= _maxSizeBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _evictionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Evict LRU entries until we have enough space
|
||||
var targetSize = _maxSizeBytes - requiredBytes;
|
||||
var entries = _index.Values
|
||||
.OrderBy(e => e.LastAccess)
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (Interlocked.Read(ref _currentSizeBytes) <= targetSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
_index.TryRemove(entry.Hash, out _);
|
||||
Interlocked.Add(ref _currentSizeBytes, -entry.Size);
|
||||
_logger?.LogDebug("Evicted cache entry {Key} ({Size} bytes)", entry.Key, entry.Size);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to evict cache entry {Key}", entry.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadIndex()
|
||||
{
|
||||
var indexPath = Path.Combine(_cachePath, IndexFileName);
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(indexPath);
|
||||
var entries = JsonSerializer.Deserialize<List<CacheEntry>>(json);
|
||||
if (entries is not null)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
_index[entry.Hash] = entry;
|
||||
Interlocked.Add(ref _currentSizeBytes, entry.Size);
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger?.LogDebug("Loaded {Count} cache entries from index", _index.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to load cache index, starting fresh");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex()
|
||||
{
|
||||
var indexPath = Path.Combine(_cachePath, IndexFileName);
|
||||
try
|
||||
{
|
||||
var entries = _index.Values.ToList();
|
||||
var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = false });
|
||||
File.WriteAllText(indexPath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to save cache index");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFilePath(string hash) => Path.Combine(_cachePath, hash);
|
||||
|
||||
private static string ComputeKeyHash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
SaveIndex();
|
||||
_evictionLock.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastAccess { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record CacheStats
|
||||
{
|
||||
public int ItemCount { get; init; }
|
||||
public long CurrentSizeBytes { get; init; }
|
||||
public long MaxSizeBytes { get; init; }
|
||||
public double UsagePercent => MaxSizeBytes > 0 ? (double)CurrentSizeBytes / MaxSizeBytes * 100 : 0;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for the Symbols service.
|
||||
/// </summary>
|
||||
public interface ISymbolsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a symbol manifest to the server.
|
||||
/// </summary>
|
||||
Task<SymbolManifestUploadResult> UploadManifestAsync(
|
||||
SymbolManifest manifest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a manifest by ID.
|
||||
/// </summary>
|
||||
Task<SymbolManifest?> GetManifestAsync(
|
||||
string manifestId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets manifests by debug ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves addresses to symbols.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolResolutionResult>> ResolveAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single address to a symbol.
|
||||
/// </summary>
|
||||
Task<SymbolResolutionResult?> ResolveAddressAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries manifests with filters.
|
||||
/// </summary>
|
||||
Task<SymbolManifestQueryResult> QueryManifestsAsync(
|
||||
SymbolManifestQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets service health status.
|
||||
/// </summary>
|
||||
Task<SymbolsHealthStatus> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest upload.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestUploadResult
|
||||
{
|
||||
public required string ManifestId { get; init; }
|
||||
public required string DebugId { get; init; }
|
||||
public required string BinaryName { get; init; }
|
||||
public string? BlobUri { get; init; }
|
||||
public required int SymbolCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolutionResult
|
||||
{
|
||||
public required ulong Address { get; init; }
|
||||
public required bool Found { get; init; }
|
||||
public string? MangledName { get; init; }
|
||||
public string? DemangledName { get; init; }
|
||||
public ulong Offset { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? SourceLine { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for manifest search.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestQuery
|
||||
{
|
||||
public string? DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public string? BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public BinaryFormat? Format { get; init; }
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
public bool? HasDsse { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest query.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestQueryResult
|
||||
{
|
||||
public required IReadOnlyList<SymbolManifestSummary> Manifests { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a symbol manifest.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestSummary
|
||||
{
|
||||
public required string ManifestId { get; init; }
|
||||
public required string DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public required string BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public required BinaryFormat Format { get; init; }
|
||||
public required int SymbolCount { get; init; }
|
||||
public required bool HasDsse { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbols service health status.
|
||||
/// </summary>
|
||||
public sealed record SymbolsHealthStatus
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public long? TotalManifests { get; init; }
|
||||
public long? TotalSymbols { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Symbols client.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with default configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(this IServiceCollection services)
|
||||
{
|
||||
return services.AddSymbolsClient(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(
|
||||
this IServiceCollection services,
|
||||
Action<SymbolsClientOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddHttpClient<ISymbolsClient, SymbolsClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SymbolsClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with a named HTTP client.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(
|
||||
this IServiceCollection services,
|
||||
string httpClientName,
|
||||
Action<SymbolsClientOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddHttpClient<ISymbolsClient, SymbolsClient>(httpClientName, (sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SymbolsClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,435 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the Symbols service.
|
||||
/// </summary>
|
||||
public sealed class SymbolsClient : ISymbolsClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SymbolsClientOptions _options;
|
||||
private readonly DiskLruCache? _cache;
|
||||
private readonly ILogger<SymbolsClient>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private bool _disposed;
|
||||
|
||||
private const string TenantHeader = "X-Tenant-Id";
|
||||
|
||||
public SymbolsClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SymbolsClientOptions> options,
|
||||
ILogger<SymbolsClient>? logger = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
if (_options.EnableDiskCache)
|
||||
{
|
||||
var cacheLogger = loggerFactory?.CreateLogger<DiskLruCache>();
|
||||
_cache = new DiskLruCache(_options.CachePath, _options.MaxCacheSizeBytes, cacheLogger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifestUploadResult> UploadManifestAsync(
|
||||
SymbolManifest manifest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var request = new UploadManifestRequest(
|
||||
DebugId: manifest.DebugId,
|
||||
BinaryName: manifest.BinaryName,
|
||||
CodeId: manifest.CodeId,
|
||||
Platform: manifest.Platform,
|
||||
Format: manifest.Format,
|
||||
Symbols: manifest.Symbols.Select(s => new SymbolEntryRequest(
|
||||
Address: s.Address,
|
||||
Size: s.Size,
|
||||
MangledName: s.MangledName,
|
||||
DemangledName: s.DemangledName,
|
||||
Type: s.Type,
|
||||
Binding: s.Binding,
|
||||
SourceFile: s.SourceFile,
|
||||
SourceLine: s.SourceLine,
|
||||
ContentHash: s.ContentHash)).ToList(),
|
||||
SourceMappings: manifest.SourceMappings?.Select(m => new SourceMappingRequest(
|
||||
CompiledPath: m.CompiledPath,
|
||||
SourcePath: m.SourcePath,
|
||||
ContentHash: m.ContentHash)).ToList());
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/symbols/manifests");
|
||||
AddTenantHeader(httpRequest);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<UploadManifestResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolManifestUploadResult
|
||||
{
|
||||
ManifestId = result!.ManifestId,
|
||||
DebugId = result.DebugId,
|
||||
BinaryName = result.BinaryName,
|
||||
BlobUri = result.BlobUri,
|
||||
SymbolCount = result.SymbolCount,
|
||||
CreatedAt = result.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifest?> GetManifestAsync(
|
||||
string manifestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/v1/symbols/manifests/{Uri.EscapeDataString(manifestId)}");
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var detail = await response.Content.ReadFromJsonAsync<ManifestDetailResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return MapToManifest(detail!);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/v1/symbols/by-debug-id/{Uri.EscapeDataString(debugId)}");
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var summaries = await response.Content.ReadFromJsonAsync<List<ManifestSummaryResponse>>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Note: This returns summaries, not full manifests. For full manifests, call GetManifestAsync for each.
|
||||
return summaries!.Select(s => new SymbolManifest
|
||||
{
|
||||
ManifestId = s.ManifestId,
|
||||
DebugId = s.DebugId,
|
||||
CodeId = s.CodeId,
|
||||
BinaryName = s.BinaryName,
|
||||
Platform = s.Platform,
|
||||
Format = s.Format,
|
||||
TenantId = _options.TenantId ?? string.Empty,
|
||||
Symbols = [],
|
||||
CreatedAt = s.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolResolutionResult>> ResolveAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
// Check cache first
|
||||
if (_cache is not null)
|
||||
{
|
||||
var cacheKey = $"resolve:{debugId}:{string.Join(",", addressList)}";
|
||||
var cached = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cached is not null)
|
||||
{
|
||||
_logger?.LogDebug("Cache hit for resolution batch");
|
||||
return JsonSerializer.Deserialize<List<SymbolResolutionResult>>(cached, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
var requestBody = new ResolveRequest(debugId, addressList);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/symbols/resolve");
|
||||
AddTenantHeader(request);
|
||||
request.Content = JsonContent.Create(requestBody, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var resolveResponse = await response.Content.ReadFromJsonAsync<ResolveResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = resolveResponse!.Resolutions.Select(r => new SymbolResolutionResult
|
||||
{
|
||||
Address = r.Address,
|
||||
Found = r.Found,
|
||||
MangledName = r.MangledName,
|
||||
DemangledName = r.DemangledName,
|
||||
Offset = r.Offset,
|
||||
SourceFile = r.SourceFile,
|
||||
SourceLine = r.SourceLine,
|
||||
Confidence = r.Confidence
|
||||
}).ToList();
|
||||
|
||||
// Cache result
|
||||
if (_cache is not null)
|
||||
{
|
||||
var cacheKey = $"resolve:{debugId}:{string.Join(",", addressList)}";
|
||||
var data = JsonSerializer.SerializeToUtf8Bytes(results, _jsonOptions);
|
||||
await _cache.SetAsync(cacheKey, data, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolResolutionResult?> ResolveAddressAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = await ResolveAsync(debugId, [address], cancellationToken).ConfigureAwait(false);
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifestQueryResult> QueryManifestsAsync(
|
||||
SymbolManifestQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrEmpty(query.DebugId)) queryParams.Add($"debugId={Uri.EscapeDataString(query.DebugId)}");
|
||||
if (!string.IsNullOrEmpty(query.CodeId)) queryParams.Add($"codeId={Uri.EscapeDataString(query.CodeId)}");
|
||||
if (!string.IsNullOrEmpty(query.BinaryName)) queryParams.Add($"binaryName={Uri.EscapeDataString(query.BinaryName)}");
|
||||
if (!string.IsNullOrEmpty(query.Platform)) queryParams.Add($"platform={Uri.EscapeDataString(query.Platform)}");
|
||||
if (query.Format.HasValue) queryParams.Add($"format={query.Format.Value}");
|
||||
if (query.CreatedAfter.HasValue) queryParams.Add($"createdAfter={query.CreatedAfter.Value:O}");
|
||||
if (query.CreatedBefore.HasValue) queryParams.Add($"createdBefore={query.CreatedBefore.Value:O}");
|
||||
if (query.HasDsse.HasValue) queryParams.Add($"hasDsse={query.HasDsse.Value}");
|
||||
queryParams.Add($"offset={query.Offset}");
|
||||
queryParams.Add($"limit={query.Limit}");
|
||||
|
||||
var url = "/v1/symbols/manifests?" + string.Join("&", queryParams);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var listResponse = await response.Content.ReadFromJsonAsync<ManifestListResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolManifestQueryResult
|
||||
{
|
||||
Manifests = listResponse!.Manifests.Select(m => new SymbolManifestSummary
|
||||
{
|
||||
ManifestId = m.ManifestId,
|
||||
DebugId = m.DebugId,
|
||||
CodeId = m.CodeId,
|
||||
BinaryName = m.BinaryName,
|
||||
Platform = m.Platform,
|
||||
Format = m.Format,
|
||||
SymbolCount = m.SymbolCount,
|
||||
HasDsse = m.HasDsse,
|
||||
CreatedAt = m.CreatedAt
|
||||
}).ToList(),
|
||||
TotalCount = listResponse.TotalCount,
|
||||
Offset = listResponse.Offset,
|
||||
Limit = listResponse.Limit
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolsHealthStatus> GetHealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var response = await _httpClient.GetAsync("/health", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var health = await response.Content.ReadFromJsonAsync<HealthResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolsHealthStatus
|
||||
{
|
||||
Status = health!.Status,
|
||||
Version = health.Version,
|
||||
Timestamp = health.Timestamp,
|
||||
TotalManifests = health.Metrics?.TotalManifests,
|
||||
TotalSymbols = health.Metrics?.TotalSymbols
|
||||
};
|
||||
}
|
||||
|
||||
private void AddTenantHeader(HttpRequestMessage request)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_options.TenantId))
|
||||
{
|
||||
request.Headers.Add(TenantHeader, _options.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private static SymbolManifest MapToManifest(ManifestDetailResponse detail)
|
||||
{
|
||||
return new SymbolManifest
|
||||
{
|
||||
ManifestId = detail.ManifestId,
|
||||
DebugId = detail.DebugId,
|
||||
CodeId = detail.CodeId,
|
||||
BinaryName = detail.BinaryName,
|
||||
Platform = detail.Platform,
|
||||
Format = detail.Format,
|
||||
TenantId = detail.TenantId,
|
||||
BlobUri = detail.BlobUri,
|
||||
DsseDigest = detail.DsseDigest,
|
||||
RekorLogIndex = detail.RekorLogIndex,
|
||||
Symbols = detail.Symbols.Select(s => new SymbolEntry
|
||||
{
|
||||
Address = s.Address,
|
||||
Size = s.Size,
|
||||
MangledName = s.MangledName,
|
||||
DemangledName = s.DemangledName,
|
||||
Type = s.Type,
|
||||
Binding = s.Binding,
|
||||
SourceFile = s.SourceFile,
|
||||
SourceLine = s.SourceLine,
|
||||
ContentHash = s.ContentHash
|
||||
}).ToList(),
|
||||
SourceMappings = detail.SourceMappings?.Select(m => new SourceMapping
|
||||
{
|
||||
CompiledPath = m.CompiledPath,
|
||||
SourcePath = m.SourcePath,
|
||||
ContentHash = m.ContentHash
|
||||
}).ToList(),
|
||||
CreatedAt = detail.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cache?.Dispose();
|
||||
}
|
||||
|
||||
// Request/Response DTOs for serialization
|
||||
private sealed record UploadManifestRequest(
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? CodeId,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
IReadOnlyList<SymbolEntryRequest> Symbols,
|
||||
IReadOnlyList<SourceMappingRequest>? SourceMappings);
|
||||
|
||||
private sealed record SymbolEntryRequest(
|
||||
ulong Address,
|
||||
ulong Size,
|
||||
string MangledName,
|
||||
string? DemangledName,
|
||||
SymbolType Type,
|
||||
SymbolBinding Binding,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
string? ContentHash);
|
||||
|
||||
private sealed record SourceMappingRequest(
|
||||
string CompiledPath,
|
||||
string SourcePath,
|
||||
string? ContentHash);
|
||||
|
||||
private sealed record UploadManifestResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? BlobUri,
|
||||
int SymbolCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestDetailResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
string TenantId,
|
||||
string? BlobUri,
|
||||
string? DsseDigest,
|
||||
long? RekorLogIndex,
|
||||
int SymbolCount,
|
||||
IReadOnlyList<SymbolEntryRequest> Symbols,
|
||||
IReadOnlyList<SourceMappingRequest>? SourceMappings,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestSummaryResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
int SymbolCount,
|
||||
bool HasDsse,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestListResponse(
|
||||
IReadOnlyList<ManifestSummaryResponse> Manifests,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
private sealed record ResolveRequest(string DebugId, IReadOnlyList<ulong> Addresses);
|
||||
|
||||
private sealed record ResolveResponse(string DebugId, IReadOnlyList<ResolutionDto> Resolutions);
|
||||
|
||||
private sealed record ResolutionDto(
|
||||
ulong Address,
|
||||
bool Found,
|
||||
string? MangledName,
|
||||
string? DemangledName,
|
||||
ulong Offset,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
double Confidence);
|
||||
|
||||
private sealed record HealthResponse(
|
||||
string Status,
|
||||
string Version,
|
||||
DateTimeOffset Timestamp,
|
||||
HealthMetrics? Metrics);
|
||||
|
||||
private sealed record HealthMetrics(
|
||||
long TotalManifests,
|
||||
long TotalSymbols,
|
||||
long TotalBlobBytes);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Symbols client.
|
||||
/// </summary>
|
||||
public sealed class SymbolsClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the Symbols server.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:5270";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for HTTP requests in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for transient failures.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Enable local disk cache for resolved symbols.
|
||||
/// </summary>
|
||||
public bool EnableDiskCache { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the disk cache directory.
|
||||
/// </summary>
|
||||
public string CachePath { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"StellaOps", "SymbolsCache");
|
||||
|
||||
/// <summary>
|
||||
/// Maximum size of disk cache in bytes (default 1GB).
|
||||
/// </summary>
|
||||
public long MaxCacheSizeBytes { get; set; } = 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID header value for multi-tenant requests.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Symbols.Client Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Symbols/StellaOps.Symbols.Client/StellaOps.Symbols.Client.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Blob store for symbol files (PDBs, DWARF, etc.).
|
||||
/// </summary>
|
||||
public interface ISymbolBlobStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a symbol blob and returns its CAS URI.
|
||||
/// </summary>
|
||||
Task<SymbolBlobUploadResult> UploadAsync(
|
||||
Stream content,
|
||||
string tenantId,
|
||||
string debugId,
|
||||
string? fileName = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a symbol blob by CAS URI.
|
||||
/// </summary>
|
||||
Task<Stream?> DownloadAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a blob exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets blob metadata without downloading content.
|
||||
/// </summary>
|
||||
Task<SymbolBlobMetadata?> GetMetadataAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a blob (requires admin).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
string blobUri,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of blob upload operation.
|
||||
/// </summary>
|
||||
public sealed record SymbolBlobUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CAS URI for the uploaded blob.
|
||||
/// </summary>
|
||||
public required string BlobUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the content.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this was a duplicate (already existed).
|
||||
/// </summary>
|
||||
public bool IsDuplicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a stored blob.
|
||||
/// </summary>
|
||||
public sealed record SymbolBlobMetadata
|
||||
{
|
||||
public required string BlobUri { get; init; }
|
||||
public required string ContentHash { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required string ContentType { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? DebugId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing and retrieving symbol manifests.
|
||||
/// </summary>
|
||||
public interface ISymbolRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a symbol manifest.
|
||||
/// </summary>
|
||||
Task<string> StoreManifestAsync(SymbolManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a manifest by ID.
|
||||
/// </summary>
|
||||
Task<SymbolManifest?> GetManifestAsync(string manifestId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves manifests by debug ID (may return multiple for different platforms).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves manifests by code ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByCodeIdAsync(
|
||||
string codeId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries manifests with filters.
|
||||
/// </summary>
|
||||
Task<SymbolQueryResult> QueryManifestsAsync(
|
||||
SymbolQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a manifest exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string manifestId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a manifest (soft delete with tombstone).
|
||||
/// </summary>
|
||||
Task<bool> DeleteManifestAsync(string manifestId, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for symbol manifests.
|
||||
/// </summary>
|
||||
public sealed record SymbolQuery
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public string? DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public string? BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public BinaryFormat? Format { get; init; }
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
public bool? HasDsse { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; } = 0;
|
||||
public string SortBy { get; init; } = "created_at";
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a symbol query.
|
||||
/// </summary>
|
||||
public sealed record SymbolQueryResult
|
||||
{
|
||||
public required IReadOnlyList<SymbolManifest> Manifests { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves symbols for addresses in binaries.
|
||||
/// </summary>
|
||||
public interface ISymbolResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a symbol at the given address.
|
||||
/// </summary>
|
||||
Task<SymbolResolution?> ResolveAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch resolve multiple addresses.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolResolution>> ResolveBatchAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all symbols for a binary.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolEntry>> GetAllSymbolsAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
SymbolType? typeFilter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// The requested address.
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if a symbol was found.
|
||||
/// </summary>
|
||||
public required bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The symbol entry if found.
|
||||
/// </summary>
|
||||
public SymbolEntry? Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset within the symbol (address - symbol.Address).
|
||||
/// </summary>
|
||||
public ulong Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Debug ID used for resolution.
|
||||
/// </summary>
|
||||
public required string DebugId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest ID that provided the symbol.
|
||||
/// </summary>
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution confidence (1.0 = exact match).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
namespace StellaOps.Symbols.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a symbol manifest containing debug symbols for a binary artifact.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this manifest (BLAKE3 hash of content).
|
||||
/// </summary>
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Debug ID (build-id or PDB GUID) for lookup.
|
||||
/// </summary>
|
||||
public required string DebugId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Code ID for the binary (GNU build-id, PE checksum, etc.).
|
||||
/// </summary>
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original binary name.
|
||||
/// </summary>
|
||||
public required string BinaryName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform/architecture (e.g., linux-x64, win-x64).
|
||||
/// </summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format (ELF, PE, Mach-O).
|
||||
/// </summary>
|
||||
public BinaryFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol entries in the manifest.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<SymbolEntry> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file mappings if available.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SourceMapping>? SourceMappings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CAS URI where the symbol blob is stored.
|
||||
/// </summary>
|
||||
public string? BlobUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope digest if signed.
|
||||
/// </summary>
|
||||
public string? DsseDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if published.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Created timestamp (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for ManifestId.
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; init; } = "blake3";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual symbol entry in a manifest.
|
||||
/// </summary>
|
||||
public sealed record SymbolEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol address (virtual address or offset).
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol size in bytes.
|
||||
/// </summary>
|
||||
public ulong Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mangled symbol name.
|
||||
/// </summary>
|
||||
public required string MangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Demangled/human-readable name.
|
||||
/// </summary>
|
||||
public string? DemangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol type (function, variable, etc.).
|
||||
/// </summary>
|
||||
public SymbolType Type { get; init; } = SymbolType.Function;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol binding (local, global, weak).
|
||||
/// </summary>
|
||||
public SymbolBinding Binding { get; init; } = SymbolBinding.Global;
|
||||
|
||||
/// <summary>
|
||||
/// Source file path if available.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number if available.
|
||||
/// </summary>
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the symbol content for deduplication.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source file mapping for source-level debugging.
|
||||
/// </summary>
|
||||
public sealed record SourceMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiled file path in binary.
|
||||
/// </summary>
|
||||
public required string CompiledPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original source file path.
|
||||
/// </summary>
|
||||
public required string SourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source content hash for verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary format types.
|
||||
/// </summary>
|
||||
public enum BinaryFormat
|
||||
{
|
||||
Unknown = 0,
|
||||
Elf = 1,
|
||||
Pe = 2,
|
||||
MachO = 3,
|
||||
Wasm = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol types.
|
||||
/// </summary>
|
||||
public enum SymbolType
|
||||
{
|
||||
Unknown = 0,
|
||||
Function = 1,
|
||||
Variable = 2,
|
||||
Object = 3,
|
||||
Section = 4,
|
||||
File = 5,
|
||||
TlsData = 6
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol binding types.
|
||||
/// </summary>
|
||||
public enum SymbolBinding
|
||||
{
|
||||
Local = 0,
|
||||
Global = 1,
|
||||
Weak = 2
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Symbols.Core Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Symbols/StellaOps.Symbols.Core/StellaOps.Symbols.Core.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,76 @@
|
||||
using Blake3;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic BLAKE3 hashing helpers for Symbols artifacts and manifests.
|
||||
/// </summary>
|
||||
public static class SymbolHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a BLAKE3 digest string with algorithm prefix.
|
||||
/// </summary>
|
||||
public static string ComputeHash(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
using var hasher = Hasher.New();
|
||||
hasher.Update(bytes);
|
||||
return $"blake3:{Convert.ToHexStringLower(hasher.Finalize().AsSpan())}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic manifest identifier from manifest identity inputs.
|
||||
/// </summary>
|
||||
public static string ComputeManifestId(
|
||||
string debugId,
|
||||
string tenantId,
|
||||
IReadOnlyList<SymbolEntry> symbols)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(debugId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
|
||||
var builder = new StringBuilder(capacity: 256 + (symbols.Count * 96));
|
||||
builder.Append("debug=").Append(debugId.Trim()).Append('\n');
|
||||
builder.Append("tenant=").Append(tenantId.Trim()).Append('\n');
|
||||
|
||||
foreach (var line in symbols
|
||||
.Select(SerializeSymbolEntry)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(line).Append('\n');
|
||||
}
|
||||
|
||||
return ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts lowercase hexadecimal digest bytes from a prefixed hash value.
|
||||
/// </summary>
|
||||
public static string ExtractHex(string hash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hash);
|
||||
|
||||
const string prefix = "blake3:";
|
||||
if (hash.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hash[prefix.Length..].ToLowerInvariant();
|
||||
}
|
||||
|
||||
return hash.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string SerializeSymbolEntry(SymbolEntry symbol)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
|
||||
static string N(string? value) => value?.Trim() ?? string.Empty;
|
||||
static string NInt(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"addr={symbol.Address:x16}|size={symbol.Size}|m={N(symbol.MangledName)}|d={N(symbol.DemangledName)}|t={symbol.Type}|b={symbol.Binding}|sf={N(symbol.SourceFile)}|sl={NInt(symbol.SourceLine)}|h={N(symbol.ContentHash)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Resolution;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of symbol resolver using the symbol repository.
|
||||
/// </summary>
|
||||
public sealed class DefaultSymbolResolver : ISymbolResolver
|
||||
{
|
||||
private readonly ISymbolRepository _repository;
|
||||
|
||||
public DefaultSymbolResolver(ISymbolRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolResolution?> ResolveAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolResolution>> ResolveBatchAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<SymbolResolution>();
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
SymbolResolution? resolution = null;
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
resolution = new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(resolution ?? new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolEntry>> GetAllSymbolsAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
SymbolType? typeFilter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var symbols = manifests
|
||||
.SelectMany(m => m.Symbols)
|
||||
.Where(s => !typeFilter.HasValue || s.Type == typeFilter.Value)
|
||||
.DistinctBy(s => s.Address)
|
||||
.OrderBy(s => s.Address)
|
||||
.ToList();
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private static SymbolEntry? FindSymbolAtAddress(IReadOnlyList<SymbolEntry> symbols, ulong address)
|
||||
{
|
||||
// Binary search for the symbol containing the address
|
||||
var left = 0;
|
||||
var right = symbols.Count - 1;
|
||||
SymbolEntry? candidate = null;
|
||||
|
||||
while (left <= right)
|
||||
{
|
||||
var mid = left + (right - left) / 2;
|
||||
var symbol = symbols[mid];
|
||||
|
||||
if (address >= symbol.Address && address < symbol.Address + symbol.Size)
|
||||
{
|
||||
return symbol; // Exact match within symbol bounds
|
||||
}
|
||||
|
||||
if (address >= symbol.Address)
|
||||
{
|
||||
candidate = symbol;
|
||||
left = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a candidate and address is within reasonable range, return it
|
||||
if (candidate is not null && address >= candidate.Address && address < candidate.Address + Math.Max(candidate.Size, 4096))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Infrastructure.Resolution;
|
||||
using StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Symbols infrastructure.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds in-memory symbol services for development and testing.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsInMemory(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolRepository, InMemorySymbolRepository>();
|
||||
services.TryAddSingleton<ISymbolBlobStore, InMemorySymbolBlobStore>();
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the default symbol resolver.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolResolver(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Infrastructure.Hashing;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol blob store for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolBlobStore : ISymbolBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BlobEntry> _blobs = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolBlobUploadResult> UploadAsync(
|
||||
Stream content,
|
||||
string tenantId,
|
||||
string debugId,
|
||||
string? fileName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var data = ms.ToArray();
|
||||
|
||||
var contentHash = SymbolHashing.ComputeHash(data);
|
||||
var blobUri = $"cas://symbols/{tenantId}/{debugId}/{SymbolHashing.ExtractHex(contentHash)}";
|
||||
|
||||
var isDuplicate = _blobs.ContainsKey(blobUri);
|
||||
|
||||
var entry = new BlobEntry(
|
||||
Data: data,
|
||||
ContentHash: contentHash,
|
||||
TenantId: tenantId,
|
||||
DebugId: debugId,
|
||||
FileName: fileName,
|
||||
ContentType: "application/octet-stream",
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
_blobs[blobUri] = entry;
|
||||
|
||||
return new SymbolBlobUploadResult
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = contentHash,
|
||||
Size = data.Length,
|
||||
IsDuplicate = isDuplicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Stream?> DownloadAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(entry.Data));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(blobUri));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolBlobMetadata?> GetMetadataAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<SymbolBlobMetadata?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<SymbolBlobMetadata?>(new SymbolBlobMetadata
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = entry.ContentHash,
|
||||
Size = entry.Data.Length,
|
||||
ContentType = entry.ContentType,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
TenantId = entry.TenantId,
|
||||
DebugId = entry.DebugId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteAsync(string blobUri, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.TryRemove(blobUri, out _));
|
||||
}
|
||||
|
||||
private sealed record BlobEntry(
|
||||
byte[] Data,
|
||||
string ContentHash,
|
||||
string TenantId,
|
||||
string DebugId,
|
||||
string? FileName,
|
||||
string ContentType,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol repository for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolRepository : ISymbolRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SymbolManifest> _manifests = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _debugIdIndex = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _codeIdIndex = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> StoreManifestAsync(SymbolManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests[manifest.ManifestId] = manifest;
|
||||
|
||||
// Update debug ID index
|
||||
_debugIdIndex.AddOrUpdate(
|
||||
manifest.DebugId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
|
||||
// Update code ID index if present
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId))
|
||||
{
|
||||
_codeIdIndex.AddOrUpdate(
|
||||
manifest.CodeId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
}
|
||||
|
||||
return Task.FromResult(manifest.ManifestId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolManifest?> GetManifestAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests.TryGetValue(manifestId, out var manifest);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_debugIdIndex.TryGetValue(debugId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByCodeIdAsync(
|
||||
string codeId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_codeIdIndex.TryGetValue(codeId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolQueryResult> QueryManifestsAsync(SymbolQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = _manifests.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
manifests = manifests.Where(m => m.TenantId == query.TenantId);
|
||||
if (!string.IsNullOrEmpty(query.DebugId))
|
||||
manifests = manifests.Where(m => m.DebugId == query.DebugId);
|
||||
if (!string.IsNullOrEmpty(query.CodeId))
|
||||
manifests = manifests.Where(m => m.CodeId == query.CodeId);
|
||||
if (!string.IsNullOrEmpty(query.BinaryName))
|
||||
manifests = manifests.Where(m => m.BinaryName.Contains(query.BinaryName, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(query.Platform))
|
||||
manifests = manifests.Where(m => m.Platform == query.Platform);
|
||||
if (query.Format.HasValue)
|
||||
manifests = manifests.Where(m => m.Format == query.Format.Value);
|
||||
if (query.CreatedAfter.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt >= query.CreatedAfter.Value);
|
||||
if (query.CreatedBefore.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt <= query.CreatedBefore.Value);
|
||||
if (query.HasDsse.HasValue)
|
||||
manifests = manifests.Where(m => !string.IsNullOrEmpty(m.DsseDigest) == query.HasDsse.Value);
|
||||
|
||||
var total = manifests.Count();
|
||||
|
||||
manifests = query.SortDescending
|
||||
? manifests.OrderByDescending(m => m.CreatedAt)
|
||||
: manifests.OrderBy(m => m.CreatedAt);
|
||||
|
||||
var result = manifests
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new SymbolQueryResult
|
||||
{
|
||||
Manifests = result,
|
||||
TotalCount = total,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_manifests.ContainsKey(manifestId));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteManifestAsync(string manifestId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_manifests.TryRemove(manifestId, out var manifest))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Remove from indexes
|
||||
if (_debugIdIndex.TryGetValue(manifest.DebugId, out var debugSet))
|
||||
{
|
||||
debugSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId) && _codeIdIndex.TryGetValue(manifest.CodeId, out var codeSet))
|
||||
{
|
||||
codeSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Symbols.Infrastructure Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Symbols/StellaOps.Symbols.Infrastructure/StellaOps.Symbols.Infrastructure.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A catalog entry representing an installable symbol/debug pack.
|
||||
/// </summary>
|
||||
public sealed record SymbolPackCatalogEntry
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid SourceId { get; init; }
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
public string Platform { get; init; } = string.Empty;
|
||||
public string[] Components { get; init; } = [];
|
||||
public string DsseDigest { get; init; } = string.Empty;
|
||||
public string Version { get; init; } = string.Empty;
|
||||
public long SizeBytes { get; init; }
|
||||
public bool Installed { get; init; }
|
||||
public DateTimeOffset PublishedAt { get; init; }
|
||||
public DateTimeOffset? InstalledAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of symbol providers (vendor/distro/community/partner).
|
||||
/// </summary>
|
||||
public sealed record SymbolPackSource
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string SourceType { get; init; } = string.Empty;
|
||||
public string? Url { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public int FreshnessSlaSeconds { get; init; } = 21600;
|
||||
public decimal WarningRatio { get; init; } = 0.80m;
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Freshness projection for a symbol source, mirroring the AdvisorySourceFreshnessRecord pattern.
|
||||
/// </summary>
|
||||
public sealed record SymbolSourceFreshnessRecord(
|
||||
Guid SourceId,
|
||||
string SourceKey,
|
||||
string SourceName,
|
||||
string SourceType,
|
||||
string? SourceUrl,
|
||||
int Priority,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
string? LastError,
|
||||
long SyncCount,
|
||||
int ErrorCount,
|
||||
int FreshnessSlaSeconds,
|
||||
decimal WarningRatio,
|
||||
long FreshnessAgeSeconds,
|
||||
string FreshnessStatus,
|
||||
string SignatureStatus,
|
||||
long TotalPacks,
|
||||
long SignedPacks,
|
||||
long UnsignedPacks,
|
||||
long SignatureFailureCount);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Four-dimension trust score for a symbol source.
|
||||
/// Each dimension is 0.0 to 1.0; Overall is a weighted average.
|
||||
/// </summary>
|
||||
public sealed record SymbolSourceTrustScore(
|
||||
double Freshness,
|
||||
double Signature,
|
||||
double Coverage,
|
||||
double SlCompliance,
|
||||
double Overall);
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Marketplace.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for marketplace catalog operations (browse, install, uninstall).
|
||||
/// </summary>
|
||||
public interface IMarketplaceCatalogRepository
|
||||
{
|
||||
Task<IReadOnlyList<SymbolPackCatalogEntry>> ListCatalogAsync(
|
||||
Guid? sourceId,
|
||||
string? search,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SymbolPackCatalogEntry?> GetCatalogEntryAsync(
|
||||
Guid entryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task InstallPackAsync(
|
||||
Guid entryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task UninstallPackAsync(
|
||||
Guid entryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<SymbolPackCatalogEntry>> ListInstalledAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Marketplace.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Read repository for symbol sources and their freshness projections.
|
||||
/// </summary>
|
||||
public interface ISymbolSourceReadRepository
|
||||
{
|
||||
Task<IReadOnlyList<SymbolSourceFreshnessRecord>> ListSourcesAsync(
|
||||
bool includeDisabled,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SymbolSourceFreshnessRecord?> GetSourceByIdAsync(
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Marketplace.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Default trust scorer using weighted dimensions:
|
||||
/// Freshness=0.3, Signature=0.3, Coverage=0.2, SLA=0.2.
|
||||
/// </summary>
|
||||
public sealed class DefaultSymbolSourceTrustScorer : ISymbolSourceTrustScorer
|
||||
{
|
||||
private const double WeightFreshness = 0.3;
|
||||
private const double WeightSignature = 0.3;
|
||||
private const double WeightCoverage = 0.2;
|
||||
private const double WeightSla = 0.2;
|
||||
|
||||
public SymbolSourceTrustScore CalculateTrust(SymbolSourceFreshnessRecord source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var freshness = ComputeFreshnessDimension(source);
|
||||
var signature = ComputeSignatureDimension(source);
|
||||
var coverage = ComputeCoverageDimension(source);
|
||||
var slCompliance = ComputeSlaDimension(source);
|
||||
|
||||
var overall =
|
||||
(freshness * WeightFreshness) +
|
||||
(signature * WeightSignature) +
|
||||
(coverage * WeightCoverage) +
|
||||
(slCompliance * WeightSla);
|
||||
|
||||
return new SymbolSourceTrustScore(
|
||||
Freshness: Clamp(freshness),
|
||||
Signature: Clamp(signature),
|
||||
Coverage: Clamp(coverage),
|
||||
SlCompliance: Clamp(slCompliance),
|
||||
Overall: Clamp(overall));
|
||||
}
|
||||
|
||||
private static double ComputeFreshnessDimension(SymbolSourceFreshnessRecord source)
|
||||
{
|
||||
if (source.FreshnessSlaSeconds <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return source.FreshnessStatus switch
|
||||
{
|
||||
"healthy" => 1.0,
|
||||
"warning" => 1.0 - ((double)source.FreshnessAgeSeconds / source.FreshnessSlaSeconds),
|
||||
"stale" => Math.Max(0.0, 0.3 - (0.1 * ((double)source.FreshnessAgeSeconds / source.FreshnessSlaSeconds - 1.0))),
|
||||
_ => 0.0 // unavailable
|
||||
};
|
||||
}
|
||||
|
||||
private static double ComputeSignatureDimension(SymbolSourceFreshnessRecord source)
|
||||
{
|
||||
if (source.TotalPacks <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (double)source.SignedPacks / source.TotalPacks;
|
||||
}
|
||||
|
||||
private static double ComputeCoverageDimension(SymbolSourceFreshnessRecord source)
|
||||
{
|
||||
// Coverage is derived from the presence of packs relative to sync activity.
|
||||
// A source with packs and no errors has full coverage potential.
|
||||
if (source.SyncCount <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if (source.TotalPacks <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
var errorRate = source.SyncCount > 0
|
||||
? (double)source.ErrorCount / source.SyncCount
|
||||
: 0.0;
|
||||
|
||||
return Math.Max(0.0, 1.0 - errorRate);
|
||||
}
|
||||
|
||||
private static double ComputeSlaDimension(SymbolSourceFreshnessRecord source)
|
||||
{
|
||||
if (source.FreshnessSlaSeconds <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// SLA compliance is based on whether the source stays within its freshness window.
|
||||
if (source.FreshnessAgeSeconds <= source.FreshnessSlaSeconds)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Degrade linearly up to 2x the SLA window, then zero.
|
||||
var overage = (double)(source.FreshnessAgeSeconds - source.FreshnessSlaSeconds) / source.FreshnessSlaSeconds;
|
||||
return Math.Max(0.0, 1.0 - overage);
|
||||
}
|
||||
|
||||
private static double Clamp(double value) => Math.Clamp(value, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.Symbols.Marketplace.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Marketplace.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a trust score for a symbol source based on freshness, signature coverage,
|
||||
/// artifact coverage, and SLA compliance.
|
||||
/// </summary>
|
||||
public interface ISymbolSourceTrustScorer
|
||||
{
|
||||
SymbolSourceTrustScore CalculateTrust(SymbolSourceFreshnessRecord source);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user