up
This commit is contained in:
109
src/Symbols/StellaOps.Symbols.Ingestor.Cli/ManifestWriter.cs
Normal file
109
src/Symbols/StellaOps.Symbols.Ingestor.Cli/ManifestWriter.cs
Normal 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;
|
||||
}
|
||||
416
src/Symbols/StellaOps.Symbols.Ingestor.Cli/Program.cs
Normal file
416
src/Symbols/StellaOps.Symbols.Ingestor.Cli/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
170
src/Symbols/StellaOps.Symbols.Ingestor.Cli/SymbolExtractor.cs
Normal file
170
src/Symbols/StellaOps.Symbols.Ingestor.Cli/SymbolExtractor.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user