This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

80
src/Symbols/AGENTS.md Normal file
View File

@@ -0,0 +1,80 @@
# Symbols Module - Agent Instructions
## Module Overview
The Symbols module provides debug symbol storage, resolution, and distribution for binary analysis and call-graph construction. It supports multi-tenant symbol management with DSSE-signed manifests and CAS-backed blob storage.
## Architecture
### Projects
- **StellaOps.Symbols.Core** - Core abstractions, models, and interfaces
- **StellaOps.Symbols.Infrastructure** - In-memory and production implementations
- **StellaOps.Symbols.Server** - REST API for symbol management
- **StellaOps.Symbols.Client** - Client SDK for Scanner/runtime integration
- **StellaOps.Symbols.Ingestor.Cli** - CLI tool for symbol ingestion
### Key Abstractions
- `ISymbolRepository` - Store and query symbol manifests
- `ISymbolBlobStore` - CAS-backed blob storage for symbol files
- `ISymbolResolver` - Address-to-symbol resolution service
### Data Model
- `SymbolManifest` - Debug symbol metadata with tenant isolation
- `SymbolEntry` - Individual symbol (function, variable) with address/name
- `SourceMapping` - Source file mappings for debugging
## API Conventions
### Endpoints
- `POST /v1/symbols/manifests` - Upload symbol manifest
- `GET /v1/symbols/manifests/{id}` - Get manifest by ID
- `GET /v1/symbols/manifests` - Query manifests with filters
- `POST /v1/symbols/resolve` - Resolve addresses to symbols
- `GET /v1/symbols/by-debug-id/{debugId}` - Get manifests by debug ID
- `GET /health` - Health check (anonymous)
### Headers
- `X-Stella-Tenant` - Required tenant ID header
- Standard Bearer authentication
### Identifiers
- `ManifestId` - BLAKE3 hash of manifest content
- `DebugId` - Build-ID (ELF) or PDB GUID
- `CodeId` - GNU build-id or PE checksum
## Storage
### CAS Paths
- Manifests: `cas://symbols/{tenant}/{debug_id}/{manifest_hash}`
- Blobs: `cas://symbols/{tenant}/{debug_id}/{content_hash}`
### Indexing
- Primary: `manifest_id`
- Secondary: `debug_id`, `code_id`, `tenant_id`
- Composite: `(tenant_id, debug_id, platform)`
## DSSE Integration
- Manifests may be DSSE-signed with `stella.ops/symbols@v1` predicate
- Rekor log index stored in manifest for transparency
- DSSE digest used for verification
## Development
### In-Memory Mode
For development, use `AddSymbolsInMemory()` which registers in-memory implementations of all services.
### Testing
- Unit tests under `__Tests/StellaOps.Symbols.Tests`
- Integration tests require Symbols Server running
- Fixtures in `tests/Symbols/fixtures/`
## Sprint References
- SYMS-SERVER-401-011 - Server bootstrap
- SYMS-CLIENT-401-012 - Client SDK
- SYMS-INGEST-401-013 - Ingestor CLI
- SYMS-BUNDLE-401-014 - Offline bundles
## Key Decisions
- BLAKE3 for content hashing (SHA256 fallback)
- Deterministic debug ID indexing
- Multi-tenant isolation via header
- In-memory default for development

View File

@@ -0,0 +1,321 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
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;
}

View File

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

View File

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

View File

@@ -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>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,434 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Symbols.Core.Models;
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<?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>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using StellaOps.Symbols.Core.Abstractions;
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();
// Compute hash (using SHA256 as placeholder for BLAKE3)
var hash = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
var blobUri = $"cas://symbols/{tenantId}/{debugId}/{hash}";
var isDuplicate = _blobs.ContainsKey(blobUri);
var entry = new BlobEntry(
Data: data,
ContentHash: hash,
TenantId: tenantId,
DebugId: debugId,
FileName: fileName,
ContentType: "application/octet-stream",
CreatedAt: DateTimeOffset.UtcNow);
_blobs[blobUri] = entry;
return new SymbolBlobUploadResult
{
BlobUri = blobUri,
ContentHash = hash,
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);
}

View File

@@ -0,0 +1,159 @@
using System.Collections.Concurrent;
using StellaOps.Symbols.Core.Abstractions;
using StellaOps.Symbols.Core.Models;
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);
}
}

View File

@@ -0,0 +1,109 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Symbols.Core.Models;
namespace StellaOps.Symbols.Ingestor.Cli;
/// <summary>
/// Writes symbol manifests to various formats.
/// </summary>
public static class ManifestWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Writes manifest to JSON file.
/// </summary>
public static async Task<string> WriteJsonAsync(
SymbolManifest manifest,
string outputDir,
CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(outputDir);
var fileName = $"{manifest.DebugId}.symbols.json";
var filePath = Path.Combine(outputDir, fileName);
var json = JsonSerializer.Serialize(manifest, JsonOptions);
await File.WriteAllTextAsync(filePath, json, cancellationToken).ConfigureAwait(false);
return filePath;
}
/// <summary>
/// Writes DSSE envelope to file.
/// </summary>
public static async Task<string> WriteDsseAsync(
string payload,
string payloadType,
string signature,
string keyId,
string outputDir,
string debugId,
CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(outputDir);
var envelope = new DsseEnvelope
{
PayloadType = payloadType,
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)),
Signatures =
[
new DsseSignature { KeyId = keyId, Sig = signature }
]
};
var fileName = $"{debugId}.symbols.dsse.json";
var filePath = Path.Combine(outputDir, fileName);
var json = JsonSerializer.Serialize(envelope, JsonOptions);
await File.WriteAllTextAsync(filePath, json, cancellationToken).ConfigureAwait(false);
return filePath;
}
/// <summary>
/// Reads manifest from JSON file.
/// </summary>
public static async Task<SymbolManifest?> ReadJsonAsync(
string filePath,
CancellationToken cancellationToken = default)
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<SymbolManifest>(json, JsonOptions);
}
}
/// <summary>
/// DSSE envelope structure.
/// </summary>
public sealed class DsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; set; } = string.Empty;
[JsonPropertyName("payload")]
public string Payload { get; set; } = string.Empty;
[JsonPropertyName("signatures")]
public List<DsseSignature> Signatures { get; set; } = [];
}
/// <summary>
/// DSSE signature.
/// </summary>
public sealed class DsseSignature
{
[JsonPropertyName("keyid")]
public string KeyId { get; set; } = string.Empty;
[JsonPropertyName("sig")]
public string Sig { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,416 @@
using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Symbols.Client;
using StellaOps.Symbols.Core.Models;
using StellaOps.Symbols.Ingestor.Cli;
return await RunAsync(args).ConfigureAwait(false);
static async Task<int> RunAsync(string[] args)
{
// Build command structure
var rootCommand = new RootCommand("StellaOps Symbol Ingestor CLI - Ingest and publish symbol manifests");
// Global options
var verboseOption = new Option<bool>("--verbose")
{
Description = "Enable verbose output"
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Dry run mode - generate manifest without uploading"
};
rootCommand.Add(verboseOption);
rootCommand.Add(dryRunOption);
// ingest command
var ingestCommand = new Command("ingest", "Ingest symbols from a binary file");
var binaryOption = new Option<string>("--binary")
{
Description = "Path to the binary file",
Required = true
};
var debugOption = new Option<string?>("--debug")
{
Description = "Path to debug symbols file (PDB, DWARF, dSYM)"
};
var debugIdOption = new Option<string?>("--debug-id")
{
Description = "Override debug ID"
};
var codeIdOption = new Option<string?>("--code-id")
{
Description = "Override code ID"
};
var nameOption = new Option<string?>("--name")
{
Description = "Override binary name"
};
var platformOption = new Option<string?>("--platform")
{
Description = "Platform identifier (linux-x64, win-x64, osx-arm64, etc.)"
};
var outputOption = new Option<string?>("--output")
{
Description = "Output directory for manifest files (default: current directory)"
};
var serverOption = new Option<string?>("--server")
{
Description = "Symbols server URL for upload"
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant ID for multi-tenant uploads"
};
ingestCommand.Add(binaryOption);
ingestCommand.Add(debugOption);
ingestCommand.Add(debugIdOption);
ingestCommand.Add(codeIdOption);
ingestCommand.Add(nameOption);
ingestCommand.Add(platformOption);
ingestCommand.Add(outputOption);
ingestCommand.Add(serverOption);
ingestCommand.Add(tenantOption);
ingestCommand.SetAction(async (parseResult, cancellationToken) =>
{
var verbose = parseResult.GetValue(verboseOption);
var dryRun = parseResult.GetValue(dryRunOption);
var binary = parseResult.GetValue(binaryOption)!;
var debug = parseResult.GetValue(debugOption);
var debugId = parseResult.GetValue(debugIdOption);
var codeId = parseResult.GetValue(codeIdOption);
var name = parseResult.GetValue(nameOption);
var platform = parseResult.GetValue(platformOption);
var output = parseResult.GetValue(outputOption) ?? ".";
var server = parseResult.GetValue(serverOption);
var tenant = parseResult.GetValue(tenantOption);
var options = new SymbolIngestOptions
{
BinaryPath = binary,
DebugPath = debug,
DebugId = debugId,
CodeId = codeId,
BinaryName = name,
Platform = platform,
OutputDir = output,
ServerUrl = server,
TenantId = tenant,
Verbose = verbose,
DryRun = dryRun
};
await IngestAsync(options, cancellationToken).ConfigureAwait(false);
});
// upload command
var uploadCommand = new Command("upload", "Upload a symbol manifest to the server");
var manifestOption = new Option<string>("--manifest")
{
Description = "Path to manifest JSON file",
Required = true
};
var uploadServerOption = new Option<string>("--server")
{
Description = "Symbols server URL",
Required = true
};
var uploadTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant ID for multi-tenant uploads"
};
uploadCommand.Add(manifestOption);
uploadCommand.Add(uploadServerOption);
uploadCommand.Add(uploadTenantOption);
uploadCommand.SetAction(async (parseResult, cancellationToken) =>
{
var verbose = parseResult.GetValue(verboseOption);
var dryRun = parseResult.GetValue(dryRunOption);
var manifestPath = parseResult.GetValue(manifestOption)!;
var server = parseResult.GetValue(uploadServerOption)!;
var tenant = parseResult.GetValue(uploadTenantOption);
await UploadAsync(manifestPath, server, tenant, verbose, dryRun, cancellationToken).ConfigureAwait(false);
});
// verify command
var verifyCommand = new Command("verify", "Verify a symbol manifest or DSSE envelope");
var verifyPathOption = new Option<string>("--path")
{
Description = "Path to manifest or DSSE file",
Required = true
};
verifyCommand.Add(verifyPathOption);
verifyCommand.SetAction(async (parseResult, cancellationToken) =>
{
var verbose = parseResult.GetValue(verboseOption);
var path = parseResult.GetValue(verifyPathOption)!;
await VerifyAsync(path, verbose, cancellationToken).ConfigureAwait(false);
});
// health command
var healthCommand = new Command("health", "Check symbols server health");
var healthServerOption = new Option<string>("--server")
{
Description = "Symbols server URL",
Required = true
};
healthCommand.Add(healthServerOption);
healthCommand.SetAction(async (parseResult, cancellationToken) =>
{
var server = parseResult.GetValue(healthServerOption)!;
await HealthCheckAsync(server, cancellationToken).ConfigureAwait(false);
});
rootCommand.Add(ingestCommand);
rootCommand.Add(uploadCommand);
rootCommand.Add(verifyCommand);
rootCommand.Add(healthCommand);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cts.Cancel();
};
var parseResult = rootCommand.Parse(args);
return await parseResult.InvokeAsync(cts.Token).ConfigureAwait(false);
}
// Command implementations
static async Task IngestAsync(SymbolIngestOptions options, CancellationToken cancellationToken)
{
AnsiConsole.MarkupLine("[bold blue]StellaOps Symbol Ingestor[/]");
AnsiConsole.WriteLine();
// Validate binary exists
if (!File.Exists(options.BinaryPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Binary file not found: {options.BinaryPath}");
Environment.ExitCode = 1;
return;
}
// Detect format
var format = SymbolExtractor.DetectFormat(options.BinaryPath);
AnsiConsole.MarkupLine($"[green]Binary format:[/] {format}");
if (format == BinaryFormat.Unknown)
{
AnsiConsole.MarkupLine("[red]Error:[/] Unknown binary format");
Environment.ExitCode = 1;
return;
}
// Create manifest
SymbolManifest manifest;
try
{
manifest = SymbolExtractor.CreateManifest(options.BinaryPath, options.DebugPath, options);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error creating manifest:[/] {ex.Message}");
Environment.ExitCode = 1;
return;
}
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
if (!string.IsNullOrEmpty(manifest.CodeId))
AnsiConsole.MarkupLine($"[green]Code ID:[/] {manifest.CodeId}");
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
AnsiConsole.MarkupLine($"[green]Platform:[/] {manifest.Platform}");
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
// Write manifest
var manifestPath = await ManifestWriter.WriteJsonAsync(manifest, options.OutputDir, cancellationToken)
.ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Manifest written:[/] {manifestPath}");
// Upload if server specified and not dry-run
if (!string.IsNullOrEmpty(options.ServerUrl) && !options.DryRun)
{
await UploadAsync(manifestPath, options.ServerUrl, options.TenantId, options.Verbose, false, cancellationToken)
.ConfigureAwait(false);
}
else if (options.DryRun)
{
AnsiConsole.MarkupLine("[yellow]Dry run mode - skipping upload[/]");
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold green]Done![/]");
}
static async Task UploadAsync(
string manifestPath,
string serverUrl,
string? tenantId,
bool verbose,
bool dryRun,
CancellationToken cancellationToken)
{
if (dryRun)
{
AnsiConsole.MarkupLine("[yellow]Dry run mode - would upload to:[/] {0}", serverUrl);
return;
}
var manifest = await ManifestWriter.ReadJsonAsync(manifestPath, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to read manifest: {manifestPath}");
Environment.ExitCode = 1;
return;
}
// Set up HTTP client and symbols client
var services = new ServiceCollection();
services.AddLogging(builder =>
{
if (verbose)
builder.AddConsole().SetMinimumLevel(LogLevel.Debug);
});
services.AddSymbolsClient(opts =>
{
opts.BaseUrl = serverUrl;
opts.TenantId = tenantId;
});
await using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ISymbolsClient>();
AnsiConsole.MarkupLine($"[blue]Uploading to:[/] {serverUrl}");
try
{
var result = await client.UploadManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Uploaded:[/] {result.ManifestId}");
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {result.SymbolCount}");
if (!string.IsNullOrEmpty(result.BlobUri))
AnsiConsole.MarkupLine($"[green]Blob URI:[/] {result.BlobUri}");
}
catch (HttpRequestException ex)
{
AnsiConsole.MarkupLine($"[red]Upload failed:[/] {ex.Message}");
Environment.ExitCode = 1;
}
}
static Task VerifyAsync(string path, bool verbose, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var json = File.ReadAllText(path);
// Check if it's a DSSE envelope or a plain manifest
if (json.Contains("\"payloadType\"") && json.Contains("\"signatures\""))
{
AnsiConsole.MarkupLine("[blue]Verifying DSSE envelope...[/]");
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json);
if (envelope is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Invalid DSSE envelope");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
AnsiConsole.MarkupLine($"[green]Payload type:[/] {envelope.PayloadType}");
AnsiConsole.MarkupLine($"[green]Signatures:[/] {envelope.Signatures.Count}");
foreach (var sig in envelope.Signatures)
{
AnsiConsole.MarkupLine($" [dim]Key ID:[/] {sig.KeyId}");
AnsiConsole.MarkupLine($" [dim]Signature:[/] {sig.Sig[..Math.Min(32, sig.Sig.Length)]}...");
}
// Decode and parse payload
try
{
var payloadJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload));
var manifest = JsonSerializer.Deserialize<SymbolManifest>(payloadJson);
if (manifest is not null)
{
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
}
}
catch
{
AnsiConsole.MarkupLine("[yellow]Warning:[/] Could not decode payload");
}
}
else
{
AnsiConsole.MarkupLine("[blue]Verifying manifest...[/]");
var manifest = JsonSerializer.Deserialize<SymbolManifest>(json);
if (manifest is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Invalid manifest");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
AnsiConsole.MarkupLine($"[green]Manifest ID:[/] {manifest.ManifestId}");
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
AnsiConsole.MarkupLine($"[green]Format:[/] {manifest.Format}");
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
AnsiConsole.MarkupLine($"[green]Created:[/] {manifest.CreatedAt:O}");
}
AnsiConsole.MarkupLine("[bold green]Verification passed![/]");
return Task.CompletedTask;
}
static async Task HealthCheckAsync(string serverUrl, CancellationToken cancellationToken)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSymbolsClient(opts => opts.BaseUrl = serverUrl);
await using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ISymbolsClient>();
AnsiConsole.MarkupLine($"[blue]Checking health:[/] {serverUrl}");
try
{
var health = await client.GetHealthAsync(cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Status:[/] {health.Status}");
AnsiConsole.MarkupLine($"[green]Version:[/] {health.Version}");
AnsiConsole.MarkupLine($"[green]Timestamp:[/] {health.Timestamp:O}");
if (health.TotalManifests.HasValue)
AnsiConsole.MarkupLine($"[green]Total manifests:[/] {health.TotalManifests}");
if (health.TotalSymbols.HasValue)
AnsiConsole.MarkupLine($"[green]Total symbols:[/] {health.TotalSymbols}");
}
catch (HttpRequestException ex)
{
AnsiConsole.MarkupLine($"[red]Health check failed:[/] {ex.Message}");
Environment.ExitCode = 1;
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<AssemblyName>stella-symbols</AssemblyName>
<RootNamespace>StellaOps.Symbols.Ingestor.Cli</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
<ProjectReference Include="..\StellaOps.Symbols.Client\StellaOps.Symbols.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,170 @@
using System.Security.Cryptography;
using StellaOps.Symbols.Core.Models;
namespace StellaOps.Symbols.Ingestor.Cli;
/// <summary>
/// Extracts symbol information from binary files.
/// </summary>
public static class SymbolExtractor
{
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7FELF
private static readonly byte[] PeMagic = [0x4D, 0x5A]; // MZ
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE]; // 0xFEEDFACE
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF]; // 0xFEEDFACF
private static readonly byte[] MachOFatMagic = [0xCA, 0xFE, 0xBA, 0xBE]; // 0xCAFEBABE
private static readonly byte[] WasmMagic = [0x00, 0x61, 0x73, 0x6D]; // \0asm
/// <summary>
/// Detects the binary format from file header.
/// </summary>
public static BinaryFormat DetectFormat(string filePath)
{
using var stream = File.OpenRead(filePath);
var header = new byte[4];
if (stream.Read(header, 0, 4) < 4)
{
return BinaryFormat.Unknown;
}
if (header.AsSpan().StartsWith(ElfMagic))
return BinaryFormat.Elf;
if (header.AsSpan(0, 2).SequenceEqual(PeMagic))
return BinaryFormat.Pe;
if (header.AsSpan().SequenceEqual(MachO32Magic) ||
header.AsSpan().SequenceEqual(MachO64Magic) ||
header.AsSpan().SequenceEqual(MachOFatMagic))
return BinaryFormat.MachO;
if (header.AsSpan().SequenceEqual(WasmMagic))
return BinaryFormat.Wasm;
return BinaryFormat.Unknown;
}
/// <summary>
/// Extracts debug ID from binary.
/// For ELF: .note.gnu.build-id
/// For PE: PDB GUID from debug directory
/// For Mach-O: LC_UUID
/// </summary>
public static string? ExtractDebugId(string filePath, BinaryFormat format)
{
// Note: Full implementation would parse each format's debug ID section.
// This is a placeholder that computes a hash-based ID.
try
{
using var stream = File.OpenRead(filePath);
var hash = SHA256.HashData(stream);
return format switch
{
BinaryFormat.Elf => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant(),
BinaryFormat.Pe => FormatPdbGuid(hash.AsSpan(0, 16)),
BinaryFormat.MachO => FormatUuid(hash.AsSpan(0, 16)),
BinaryFormat.Wasm => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant(),
_ => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant()
};
}
catch
{
return null;
}
}
/// <summary>
/// Extracts code ID (optional, format-specific).
/// </summary>
public static string? ExtractCodeId(string filePath, BinaryFormat format)
{
// Code ID is typically derived from:
// - PE: TimeDateStamp + SizeOfImage
// - ELF: Same as build-id for most cases
// - Mach-O: Same as UUID
return null; // Placeholder
}
/// <summary>
/// Computes content hash for a file using BLAKE3 (or SHA256 fallback).
/// </summary>
public static string ComputeContentHash(string filePath)
{
using var stream = File.OpenRead(filePath);
// Using SHA256 as placeholder until BLAKE3 is integrated
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Creates a symbol manifest from binary analysis.
/// </summary>
public static SymbolManifest CreateManifest(
string binaryPath,
string? debugPath,
SymbolIngestOptions options)
{
var format = DetectFormat(binaryPath);
if (format == BinaryFormat.Unknown)
{
throw new InvalidOperationException($"Unknown binary format: {binaryPath}");
}
var debugId = options.DebugId ?? ExtractDebugId(binaryPath, format)
?? throw new InvalidOperationException($"Could not extract debug ID from: {binaryPath}");
var codeId = options.CodeId ?? ExtractCodeId(binaryPath, format);
var binaryName = options.BinaryName ?? Path.GetFileName(binaryPath);
var platform = options.Platform ?? DetectPlatform(format);
// Note: Full implementation would parse symbol tables from binary/debug files
// For now, create manifest with metadata only
var symbols = new List<SymbolEntry>();
// If debug file exists, record its hash
string? debugContentHash = null;
if (!string.IsNullOrEmpty(debugPath) && File.Exists(debugPath))
{
debugContentHash = ComputeContentHash(debugPath);
}
return new SymbolManifest
{
ManifestId = Guid.NewGuid().ToString("N"),
DebugId = debugId,
CodeId = codeId,
BinaryName = binaryName,
Platform = platform,
Format = format,
TenantId = options.TenantId ?? "default",
Symbols = symbols,
SourceMappings = null,
CreatedAt = DateTimeOffset.UtcNow
};
}
private static string FormatPdbGuid(ReadOnlySpan<byte> bytes)
{
// Format as GUID + age (simplified)
var guid = new Guid(bytes.ToArray());
return guid.ToString("N").ToUpperInvariant() + "1";
}
private static string FormatUuid(ReadOnlySpan<byte> bytes)
{
// Format as UUID (hyphenated)
var guid = new Guid(bytes.ToArray());
return guid.ToString("D").ToUpperInvariant();
}
private static string DetectPlatform(BinaryFormat format)
{
// Default platform detection based on format and runtime
return format switch
{
BinaryFormat.Pe => "win-x64",
BinaryFormat.MachO => OperatingSystem.IsMacOS() ? "osx-arm64" : "osx-x64",
BinaryFormat.Elf => "linux-x64",
BinaryFormat.Wasm => "wasm32",
_ => "unknown"
};
}
}

View File

@@ -0,0 +1,82 @@
namespace StellaOps.Symbols.Ingestor.Cli;
/// <summary>
/// Options for symbol ingestion.
/// </summary>
public sealed class SymbolIngestOptions
{
/// <summary>
/// Path to the binary file (ELF, PE, Mach-O, WASM).
/// </summary>
public string BinaryPath { get; set; } = string.Empty;
/// <summary>
/// Path to the debug symbols file (PDB, DWARF, dSYM).
/// </summary>
public string? DebugPath { get; set; }
/// <summary>
/// Override debug ID (otherwise extracted from binary).
/// </summary>
public string? DebugId { get; set; }
/// <summary>
/// Override code ID (otherwise extracted from binary).
/// </summary>
public string? CodeId { get; set; }
/// <summary>
/// Override binary name (otherwise derived from file name).
/// </summary>
public string? BinaryName { get; set; }
/// <summary>
/// Platform identifier (linux-x64, win-x64, osx-arm64, etc.).
/// </summary>
public string? Platform { get; set; }
/// <summary>
/// Output directory for manifest files.
/// </summary>
public string OutputDir { get; set; } = ".";
/// <summary>
/// Symbols server URL for upload.
/// </summary>
public string? ServerUrl { get; set; }
/// <summary>
/// Tenant ID for multi-tenant uploads.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Sign the manifest with DSSE.
/// </summary>
public bool Sign { get; set; }
/// <summary>
/// Path to signing key (for DSSE signing).
/// </summary>
public string? SigningKeyPath { get; set; }
/// <summary>
/// Submit to Rekor transparency log.
/// </summary>
public bool SubmitRekor { get; set; }
/// <summary>
/// Rekor server URL.
/// </summary>
public string RekorUrl { get; set; } = "https://rekor.sigstore.dev";
/// <summary>
/// Emit verbose output.
/// </summary>
public bool Verbose { get; set; }
/// <summary>
/// Dry run mode - generate manifest without uploading.
/// </summary>
public bool DryRun { get; set; }
}

View File

@@ -0,0 +1,134 @@
using StellaOps.Symbols.Core.Models;
namespace StellaOps.Symbols.Server.Contracts;
/// <summary>
/// Request to upload a symbol manifest.
/// </summary>
public sealed record UploadSymbolManifestRequest(
string DebugId,
string BinaryName,
string? CodeId,
string? Platform,
BinaryFormat Format,
IReadOnlyList<SymbolEntryDto> Symbols,
IReadOnlyList<SourceMappingDto>? SourceMappings);
/// <summary>
/// Symbol entry DTO for API.
/// </summary>
public sealed record SymbolEntryDto(
ulong Address,
ulong Size,
string MangledName,
string? DemangledName,
SymbolType Type,
SymbolBinding Binding,
string? SourceFile,
int? SourceLine,
string? ContentHash);
/// <summary>
/// Source mapping DTO for API.
/// </summary>
public sealed record SourceMappingDto(
string CompiledPath,
string SourcePath,
string? ContentHash);
/// <summary>
/// Response from manifest upload.
/// </summary>
public sealed record UploadSymbolManifestResponse(
string ManifestId,
string DebugId,
string BinaryName,
string? BlobUri,
int SymbolCount,
DateTimeOffset CreatedAt);
/// <summary>
/// Request to resolve symbols.
/// </summary>
public sealed record ResolveSymbolsRequest(
string DebugId,
IReadOnlyList<ulong> Addresses);
/// <summary>
/// Response from symbol resolution.
/// </summary>
public sealed record ResolveSymbolsResponse(
string DebugId,
IReadOnlyList<SymbolResolutionDto> Resolutions);
/// <summary>
/// Symbol resolution DTO.
/// </summary>
public sealed record SymbolResolutionDto(
ulong Address,
bool Found,
string? MangledName,
string? DemangledName,
ulong Offset,
string? SourceFile,
int? SourceLine,
double Confidence);
/// <summary>
/// Symbol manifest list response.
/// </summary>
public sealed record SymbolManifestListResponse(
IReadOnlyList<SymbolManifestSummary> Manifests,
int TotalCount,
int Offset,
int Limit);
/// <summary>
/// Summary of a symbol manifest.
/// </summary>
public sealed record SymbolManifestSummary(
string ManifestId,
string DebugId,
string? CodeId,
string BinaryName,
string? Platform,
BinaryFormat Format,
int SymbolCount,
bool HasDsse,
DateTimeOffset CreatedAt);
/// <summary>
/// Detailed manifest response.
/// </summary>
public sealed record SymbolManifestDetailResponse(
string ManifestId,
string DebugId,
string? CodeId,
string BinaryName,
string? Platform,
BinaryFormat Format,
string TenantId,
string? BlobUri,
string? DsseDigest,
long? RekorLogIndex,
int SymbolCount,
IReadOnlyList<SymbolEntryDto> Symbols,
IReadOnlyList<SourceMappingDto>? SourceMappings,
DateTimeOffset CreatedAt);
/// <summary>
/// Health check response.
/// </summary>
public sealed record SymbolsHealthResponse(
string Status,
string Version,
DateTimeOffset Timestamp,
SymbolsHealthMetrics? Metrics);
/// <summary>
/// Health metrics.
/// </summary>
public sealed record SymbolsHealthMetrics(
long TotalManifests,
long TotalSymbols,
long TotalBlobBytes);

View File

@@ -0,0 +1,323 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Symbols.Core.Abstractions;
using StellaOps.Symbols.Core.Models;
using StellaOps.Symbols.Infrastructure;
using StellaOps.Symbols.Server.Contracts;
var builder = WebApplication.CreateBuilder(args);
// Authentication and Authorization
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configure: options =>
{
options.RequiredScopes.Clear();
});
builder.Services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.FallbackPolicy = options.DefaultPolicy;
});
// Symbols services (in-memory for development)
builder.Services.AddSymbolsInMemory();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
const string SymbolsReadPolicy = "symbols:read";
const string SymbolsWritePolicy = "symbols:write";
// Health endpoint (anonymous)
app.MapGet("/health", () =>
{
return TypedResults.Ok(new SymbolsHealthResponse(
Status: "healthy",
Version: "1.0.0",
Timestamp: DateTimeOffset.UtcNow,
Metrics: null));
})
.AllowAnonymous()
.WithName("GetHealth")
.WithSummary("Health check endpoint");
// Upload symbol manifest
app.MapPost("/v1/symbols/manifests", async Task<Results<Created<UploadSymbolManifestResponse>, ProblemHttpResult>> (
HttpContext httpContext,
UploadSymbolManifestRequest request,
ISymbolRepository repository,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var symbols = request.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();
var sourceMappings = request.SourceMappings?.Select(m => new SourceMapping
{
CompiledPath = m.CompiledPath,
SourcePath = m.SourcePath,
ContentHash = m.ContentHash
}).ToList();
var manifestId = ComputeManifestId(request.DebugId, tenantId, symbols);
var manifest = new SymbolManifest
{
ManifestId = manifestId,
DebugId = request.DebugId,
CodeId = request.CodeId,
BinaryName = request.BinaryName,
Platform = request.Platform,
Format = request.Format,
Symbols = symbols,
SourceMappings = sourceMappings,
TenantId = tenantId,
CreatedAt = DateTimeOffset.UtcNow
};
await repository.StoreManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
var response = new UploadSymbolManifestResponse(
ManifestId: manifestId,
DebugId: request.DebugId,
BinaryName: request.BinaryName,
BlobUri: manifest.BlobUri,
SymbolCount: symbols.Count,
CreatedAt: manifest.CreatedAt);
return TypedResults.Created($"/v1/symbols/manifests/{manifestId}", response);
})
.RequireAuthorization()
.WithName("UploadSymbolManifest")
.WithSummary("Upload a symbol manifest")
.Produces(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Get manifest by ID
app.MapGet("/v1/symbols/manifests/{manifestId}", async Task<Results<Ok<SymbolManifestDetailResponse>, NotFound, ProblemHttpResult>> (
string manifestId,
ISymbolRepository repository,
CancellationToken cancellationToken) =>
{
var manifest = await repository.GetManifestAsync(manifestId, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
return TypedResults.NotFound();
}
var response = MapToDetailResponse(manifest);
return TypedResults.Ok(response);
})
.RequireAuthorization()
.WithName("GetSymbolManifest")
.WithSummary("Get symbol manifest by ID")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Query manifests
app.MapGet("/v1/symbols/manifests", async Task<Results<Ok<SymbolManifestListResponse>, ProblemHttpResult>> (
HttpContext httpContext,
ISymbolRepository repository,
string? debugId,
string? codeId,
string? binaryName,
string? platform,
int? limit,
int? offset,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var query = new SymbolQuery
{
TenantId = tenantId,
DebugId = debugId,
CodeId = codeId,
BinaryName = binaryName,
Platform = platform,
Limit = limit ?? 50,
Offset = offset ?? 0
};
var result = await repository.QueryManifestsAsync(query, cancellationToken).ConfigureAwait(false);
var summaries = result.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.Symbols.Count,
HasDsse: !string.IsNullOrEmpty(m.DsseDigest),
CreatedAt: m.CreatedAt)).ToList();
return TypedResults.Ok(new SymbolManifestListResponse(
Manifests: summaries,
TotalCount: result.TotalCount,
Offset: result.Offset,
Limit: result.Limit));
})
.RequireAuthorization()
.WithName("QuerySymbolManifests")
.WithSummary("Query symbol manifests")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Resolve symbols
app.MapPost("/v1/symbols/resolve", async Task<Results<Ok<ResolveSymbolsResponse>, ProblemHttpResult>> (
HttpContext httpContext,
ResolveSymbolsRequest request,
ISymbolResolver resolver,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var resolutions = await resolver.ResolveBatchAsync(
request.DebugId,
request.Addresses,
tenantId,
cancellationToken).ConfigureAwait(false);
var dtos = resolutions.Select(r => new SymbolResolutionDto(
Address: r.Address,
Found: r.Found,
MangledName: r.Symbol?.MangledName,
DemangledName: r.Symbol?.DemangledName,
Offset: r.Offset,
SourceFile: r.Symbol?.SourceFile,
SourceLine: r.Symbol?.SourceLine,
Confidence: r.Confidence)).ToList();
return TypedResults.Ok(new ResolveSymbolsResponse(
DebugId: request.DebugId,
Resolutions: dtos));
})
.RequireAuthorization()
.WithName("ResolveSymbols")
.WithSummary("Resolve symbol addresses")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Get manifests by debug ID
app.MapGet("/v1/symbols/by-debug-id/{debugId}", async Task<Results<Ok<SymbolManifestListResponse>, ProblemHttpResult>> (
HttpContext httpContext,
string debugId,
ISymbolRepository repository,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var manifests = await repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
.ConfigureAwait(false);
var summaries = 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.Symbols.Count,
HasDsse: !string.IsNullOrEmpty(m.DsseDigest),
CreatedAt: m.CreatedAt)).ToList();
return TypedResults.Ok(new SymbolManifestListResponse(
Manifests: summaries,
TotalCount: summaries.Count,
Offset: 0,
Limit: summaries.Count));
})
.RequireAuthorization()
.WithName("GetManifestsByDebugId")
.WithSummary("Get manifests by debug ID")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.Run();
static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId)
{
tenantId = string.Empty;
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) ||
string.IsNullOrWhiteSpace(tenantValues))
{
problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
return false;
}
tenantId = tenantValues.ToString();
problem = null;
return true;
}
static string ComputeManifestId(string debugId, string tenantId, IReadOnlyList<SymbolEntry> symbols)
{
// Simplified hash computation (should use BLAKE3 in production)
var combined = $"{debugId}:{tenantId}:{symbols.Count}:{DateTimeOffset.UtcNow.Ticks}";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
}
static SymbolManifestDetailResponse MapToDetailResponse(SymbolManifest manifest)
{
return new SymbolManifestDetailResponse(
ManifestId: manifest.ManifestId,
DebugId: manifest.DebugId,
CodeId: manifest.CodeId,
BinaryName: manifest.BinaryName,
Platform: manifest.Platform,
Format: manifest.Format,
TenantId: manifest.TenantId,
BlobUri: manifest.BlobUri,
DsseDigest: manifest.DsseDigest,
RekorLogIndex: manifest.RekorLogIndex,
SymbolCount: manifest.Symbols.Count,
Symbols: manifest.Symbols.Select(s => new SymbolEntryDto(
s.Address, s.Size, s.MangledName, s.DemangledName,
s.Type, s.Binding, s.SourceFile, s.SourceLine, s.ContentHash)).ToList(),
SourceMappings: manifest.SourceMappings?.Select(m => new SourceMappingDto(
m.CompiledPath, m.SourcePath, m.ContentHash)).ToList(),
CreatedAt: manifest.CreatedAt);
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
<ProjectReference Include="..\StellaOps.Symbols.Infrastructure\StellaOps.Symbols.Infrastructure.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>