up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for language-specific static lifters that extract callgraph edges
|
||||
/// and symbol definitions for reachability analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementers must produce deterministic output: stable ordering, no randomness,
|
||||
/// and normalized symbol IDs using <see cref="SymbolId"/> helpers.
|
||||
/// </remarks>
|
||||
public interface IReachabilityLifter
|
||||
{
|
||||
/// <summary>
|
||||
/// Language identifier (e.g., "java", "dotnet", "node").
|
||||
/// Must match <see cref="SymbolId.Lang"/> constants.
|
||||
/// </summary>
|
||||
string Language { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifts static callgraph information from analyzed artifacts.
|
||||
/// </summary>
|
||||
/// <param name="context">Analysis context with filesystem access.</param>
|
||||
/// <param name="builder">Builder to emit nodes and edges.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task that completes when lifting is done.</returns>
|
||||
ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to reachability lifters during analysis.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLifterContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Root path of the analysis target (workspace, container layer, etc.).
|
||||
/// </summary>
|
||||
public required string RootPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis ID for CAS namespacing.
|
||||
/// </summary>
|
||||
public required string AnalysisId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional layer digest for container analysis.
|
||||
/// </summary>
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional entrypoint hint from image config.
|
||||
/// </summary>
|
||||
public string? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional options for lifter behavior.
|
||||
/// </summary>
|
||||
public ReachabilityLifterOptions Options { get; init; } = ReachabilityLifterOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling reachability lifter behavior.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLifterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default options for production use.
|
||||
/// </summary>
|
||||
public static ReachabilityLifterOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Include edges with low confidence (dynamic/reflection patterns).
|
||||
/// </summary>
|
||||
public bool IncludeLowConfidenceEdges { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include framework/runtime symbols in the graph.
|
||||
/// </summary>
|
||||
public bool IncludeFrameworkSymbols { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for transitive edge discovery.
|
||||
/// </summary>
|
||||
public int MaxTransitiveDepth { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability lifter for .NET projects.
|
||||
/// Extracts callgraph edges from project references, package references, and assembly metadata.
|
||||
/// </summary>
|
||||
public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
|
||||
{
|
||||
public string Language => SymbolId.Lang.DotNet;
|
||||
|
||||
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var rootPath = context.RootPath;
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all project files
|
||||
var projectFiles = Directory.EnumerateFiles(rootPath, "*.csproj", SearchOption.AllDirectories)
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.fsproj", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.vbproj", SearchOption.AllDirectories))
|
||||
.Where(p => !p.Contains("obj", StringComparison.OrdinalIgnoreCase) || !IsObjFolder(rootPath, p))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Build project graph
|
||||
var projectGraph = new Dictionary<string, ProjectInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var projectFile in projectFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var info = await ParseProjectFileAsync(context, projectFile, cancellationToken).ConfigureAwait(false);
|
||||
if (info is not null)
|
||||
{
|
||||
projectGraph[projectFile] = info;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit nodes and edges
|
||||
foreach (var (projectPath, info) in projectGraph.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
EmitProjectNodes(context, builder, info);
|
||||
EmitProjectEdges(context, builder, info, projectGraph);
|
||||
}
|
||||
|
||||
// Process deps.json files for runtime assembly information
|
||||
await ProcessDepsJsonFilesAsync(context, builder, rootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsObjFolder(string rootPath, string path)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, path);
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return parts.Any(p => p.Equals("obj", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async ValueTask<ProjectInfo?> ParseProjectFileAsync(
|
||||
ReachabilityLifterContext context,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(projectPath, cancellationToken).ConfigureAwait(false);
|
||||
var doc = XDocument.Parse(content);
|
||||
var root = doc.Root;
|
||||
if (root is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assemblyName = root.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "AssemblyName")?.Value
|
||||
?? Path.GetFileNameWithoutExtension(projectPath);
|
||||
|
||||
var targetFramework = root.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "TargetFramework")?.Value;
|
||||
|
||||
var targetFrameworks = root.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "TargetFrameworks")?.Value;
|
||||
|
||||
var rootNamespace = root.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "RootNamespace")?.Value
|
||||
?? assemblyName;
|
||||
|
||||
var packageRefs = root.Descendants()
|
||||
.Where(e => e.Name.LocalName == "PackageReference")
|
||||
.Select(e => new PackageRef(
|
||||
e.Attribute("Include")?.Value ?? string.Empty,
|
||||
e.Attribute("Version")?.Value ?? e.Descendants().FirstOrDefault(d => d.Name.LocalName == "Version")?.Value ?? "*"))
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p.Name))
|
||||
.ToList();
|
||||
|
||||
var projectRefs = root.Descendants()
|
||||
.Where(e => e.Name.LocalName == "ProjectReference")
|
||||
.Select(e => e.Attribute("Include")?.Value ?? string.Empty)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.ToList();
|
||||
|
||||
var frameworkRefs = root.Descendants()
|
||||
.Where(e => e.Name.LocalName == "FrameworkReference")
|
||||
.Select(e => e.Attribute("Include")?.Value ?? string.Empty)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.ToList();
|
||||
|
||||
return new ProjectInfo(
|
||||
projectPath,
|
||||
assemblyName,
|
||||
rootNamespace,
|
||||
targetFramework ?? targetFrameworks?.Split(';').FirstOrDefault() ?? "net8.0",
|
||||
packageRefs,
|
||||
projectRefs,
|
||||
frameworkRefs);
|
||||
}
|
||||
catch (Exception) when (IsExpectedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Exception filter pattern for expected exceptions
|
||||
private static bool IsExpectedException => true;
|
||||
|
||||
private static void EmitProjectNodes(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
ProjectInfo info)
|
||||
{
|
||||
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, info.ProjectPath));
|
||||
|
||||
// Add assembly node
|
||||
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
|
||||
builder.AddNode(
|
||||
symbolId: assemblySymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "assembly",
|
||||
display: info.AssemblyName,
|
||||
sourceFile: relativePath,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["target_framework"] = info.TargetFramework,
|
||||
["root_namespace"] = info.RootNamespace
|
||||
});
|
||||
|
||||
// Add namespace node
|
||||
if (!string.IsNullOrWhiteSpace(info.RootNamespace))
|
||||
{
|
||||
var nsSymbol = SymbolId.ForDotNet(info.AssemblyName, info.RootNamespace, string.Empty, string.Empty);
|
||||
builder.AddNode(
|
||||
symbolId: nsSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "namespace",
|
||||
display: info.RootNamespace,
|
||||
sourceFile: relativePath);
|
||||
|
||||
builder.AddEdge(
|
||||
from: assemblySymbol,
|
||||
to: nsSymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativePath}:RootNamespace");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitProjectEdges(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
ProjectInfo info,
|
||||
Dictionary<string, ProjectInfo> projectGraph)
|
||||
{
|
||||
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, info.ProjectPath));
|
||||
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
// Package references
|
||||
foreach (var pkgRef in info.PackageReferences.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var pkgSymbol = SymbolId.ForDotNet(pkgRef.Name, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: pkgSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "package",
|
||||
display: pkgRef.Name,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = pkgRef.Version,
|
||||
["purl"] = $"pkg:nuget/{pkgRef.Name}@{pkgRef.Version}"
|
||||
});
|
||||
|
||||
builder.AddEdge(
|
||||
from: assemblySymbol,
|
||||
to: pkgSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativePath}:PackageReference.{pkgRef.Name}");
|
||||
}
|
||||
|
||||
// Project references
|
||||
foreach (var projRef in info.ProjectReferences.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var refPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(info.ProjectPath) ?? string.Empty, projRef));
|
||||
if (projectGraph.TryGetValue(refPath, out var refInfo))
|
||||
{
|
||||
var refSymbol = SymbolId.ForDotNet(refInfo.AssemblyName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddEdge(
|
||||
from: assemblySymbol,
|
||||
to: refSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativePath}:ProjectReference");
|
||||
}
|
||||
}
|
||||
|
||||
// Framework references
|
||||
foreach (var fwRef in info.FrameworkReferences.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var fwSymbol = SymbolId.ForDotNet(fwRef, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: fwSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "framework",
|
||||
display: fwRef);
|
||||
|
||||
builder.AddEdge(
|
||||
from: assemblySymbol,
|
||||
to: fwSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativePath}:FrameworkReference.{fwRef}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessDepsJsonFilesAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var depsFiles = Directory.EnumerateFiles(rootPath, "*.deps.json", SearchOption.AllDirectories)
|
||||
.Where(p => p.Contains("bin", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Contains("publish", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Take(10); // Limit to prevent huge processing
|
||||
|
||||
foreach (var depsFile in depsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessDepsJsonAsync(context, builder, depsFile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessDepsJsonAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string depsFile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(depsFile, cancellationToken).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract runtime target
|
||||
if (root.TryGetProperty("runtimeTarget", out var runtimeTarget) &&
|
||||
runtimeTarget.TryGetProperty("name", out var runtimeName))
|
||||
{
|
||||
var targetName = runtimeName.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(targetName))
|
||||
{
|
||||
// Process targets for this runtime
|
||||
if (root.TryGetProperty("targets", out var targets) &&
|
||||
targets.TryGetProperty(targetName, out var targetLibs))
|
||||
{
|
||||
ProcessTargetLibraries(context, builder, targetLibs, depsFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process libraries for version info
|
||||
if (root.TryGetProperty("libraries", out var libraries))
|
||||
{
|
||||
ProcessLibraries(context, builder, libraries, depsFile);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Invalid JSON
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File access issue
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessTargetLibraries(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement targetLibs,
|
||||
string depsFile)
|
||||
{
|
||||
var relativeDepsPath = NormalizePath(Path.GetRelativePath(context.RootPath, depsFile));
|
||||
|
||||
foreach (var lib in targetLibs.EnumerateObject())
|
||||
{
|
||||
var libKey = lib.Name; // format: "PackageName/Version"
|
||||
var slashIndex = libKey.IndexOf('/');
|
||||
if (slashIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = libKey[..slashIndex];
|
||||
var version = libKey[(slashIndex + 1)..];
|
||||
|
||||
var libSymbol = SymbolId.ForDotNet(packageName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: libSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "library",
|
||||
display: packageName,
|
||||
sourceFile: relativeDepsPath,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = version,
|
||||
["purl"] = $"pkg:nuget/{packageName}@{version}"
|
||||
});
|
||||
|
||||
// Process dependencies
|
||||
if (lib.Value.TryGetProperty("dependencies", out var deps))
|
||||
{
|
||||
foreach (var dep in deps.EnumerateObject())
|
||||
{
|
||||
var depName = dep.Name;
|
||||
var depVersion = dep.Value.GetString() ?? "*";
|
||||
|
||||
var depSymbol = SymbolId.ForDotNet(depName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: depSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "library",
|
||||
display: depName);
|
||||
|
||||
builder.AddEdge(
|
||||
from: libSymbol,
|
||||
to: depSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativeDepsPath}:dependencies.{depName}");
|
||||
}
|
||||
}
|
||||
|
||||
// Process runtime assemblies
|
||||
if (lib.Value.TryGetProperty("runtime", out var runtime))
|
||||
{
|
||||
foreach (var asm in runtime.EnumerateObject())
|
||||
{
|
||||
var asmPath = asm.Name;
|
||||
var asmName = Path.GetFileNameWithoutExtension(asmPath);
|
||||
|
||||
if (!string.Equals(asmName, packageName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var asmSymbol = SymbolId.ForDotNet(asmName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: asmSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "assembly",
|
||||
display: asmName);
|
||||
|
||||
builder.AddEdge(
|
||||
from: libSymbol,
|
||||
to: asmSymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: $"file:{relativeDepsPath}:runtime.{asmPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessLibraries(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement libraries,
|
||||
string depsFile)
|
||||
{
|
||||
var relativeDepsPath = NormalizePath(Path.GetRelativePath(context.RootPath, depsFile));
|
||||
|
||||
foreach (var lib in libraries.EnumerateObject())
|
||||
{
|
||||
var libKey = lib.Name;
|
||||
var slashIndex = libKey.IndexOf('/');
|
||||
if (slashIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = libKey[..slashIndex];
|
||||
var version = libKey[(slashIndex + 1)..];
|
||||
|
||||
if (lib.Value.TryGetProperty("type", out var typeEl))
|
||||
{
|
||||
var libType = typeEl.GetString();
|
||||
var kind = libType switch
|
||||
{
|
||||
"project" => "project",
|
||||
"package" => "package",
|
||||
_ => "library"
|
||||
};
|
||||
|
||||
var libSymbol = SymbolId.ForDotNet(packageName, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: libSymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: kind,
|
||||
display: packageName,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = version,
|
||||
["type"] = libType ?? "unknown"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) => path.Replace('\\', '/');
|
||||
|
||||
private sealed record ProjectInfo(
|
||||
string ProjectPath,
|
||||
string AssemblyName,
|
||||
string RootNamespace,
|
||||
string TargetFramework,
|
||||
List<PackageRef> PackageReferences,
|
||||
List<string> ProjectReferences,
|
||||
List<string> FrameworkReferences);
|
||||
|
||||
private sealed record PackageRef(string Name, string Version);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability lifter for Node.js/npm projects.
|
||||
/// Extracts callgraph edges from import/require statements and builds symbol IDs.
|
||||
/// </summary>
|
||||
public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
{
|
||||
public string Language => SymbolId.Lang.Node;
|
||||
|
||||
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var rootPath = context.RootPath;
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all package.json files
|
||||
var packageJsonFiles = Directory.EnumerateFiles(rootPath, "package.json", SearchOption.AllDirectories)
|
||||
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase) || IsDirectDependency(rootPath, p))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var packageJsonPath in packageJsonFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessPackageAsync(context, builder, packageJsonPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Process JS/TS files for import edges
|
||||
await ProcessSourceFilesAsync(context, builder, rootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsDirectDependency(string rootPath, string packageJsonPath)
|
||||
{
|
||||
// Check if it's a direct dependency in node_modules (not nested)
|
||||
var relativePath = Path.GetRelativePath(rootPath, packageJsonPath);
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
// Direct dep: node_modules/<pkg>/package.json or node_modules/@scope/pkg/package.json
|
||||
if (parts.Length < 2 || !parts[0].Equals("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count how many node_modules segments there are
|
||||
var nodeModulesCount = parts.Count(p => p.Equals("node_modules", StringComparison.OrdinalIgnoreCase));
|
||||
return nodeModulesCount == 1;
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessPackageAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string packageJsonPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(packageJsonPath, cancellationToken).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var pkgName = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(pkgName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pkgVersion = root.TryGetProperty("version", out var verEl) ? verEl.GetString() : "0.0.0";
|
||||
|
||||
// Add package as a module node
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
var relativePath = Path.GetRelativePath(context.RootPath, packageJsonPath);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: moduleSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "module",
|
||||
display: pkgName,
|
||||
sourceFile: NormalizePath(relativePath),
|
||||
sourceLine: null,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = pkgVersion ?? "0.0.0",
|
||||
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}"
|
||||
});
|
||||
|
||||
// Process entrypoints (main, module, exports)
|
||||
ProcessEntrypoints(context, builder, root, pkgName, relativePath);
|
||||
|
||||
// Process dependencies as edges
|
||||
ProcessDependencies(builder, root, pkgName, "dependencies", EdgeConfidence.Certain);
|
||||
ProcessDependencies(builder, root, pkgName, "devDependencies", EdgeConfidence.Medium);
|
||||
ProcessDependencies(builder, root, pkgName, "peerDependencies", EdgeConfidence.High);
|
||||
ProcessDependencies(builder, root, pkgName, "optionalDependencies", EdgeConfidence.Low);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File access issue, skip
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessEntrypoints(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement root,
|
||||
string pkgName,
|
||||
string packageJsonPath)
|
||||
{
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
|
||||
// Process "main" field
|
||||
if (root.TryGetProperty("main", out var mainEl) && mainEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var mainPath = mainEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(mainPath))
|
||||
{
|
||||
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(mainPath), "entrypoint");
|
||||
builder.AddNode(
|
||||
symbolId: entrySymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{mainPath}",
|
||||
sourceFile: NormalizePath(mainPath));
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: entrySymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:main");
|
||||
}
|
||||
}
|
||||
|
||||
// Process "module" field (ESM entry)
|
||||
if (root.TryGetProperty("module", out var moduleEl) && moduleEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var modulePath = moduleEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(modulePath))
|
||||
{
|
||||
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(modulePath), "entrypoint");
|
||||
builder.AddNode(
|
||||
symbolId: entrySymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{modulePath} (ESM)",
|
||||
sourceFile: NormalizePath(modulePath));
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: entrySymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:module");
|
||||
}
|
||||
}
|
||||
|
||||
// Process "bin" field
|
||||
if (root.TryGetProperty("bin", out var binEl))
|
||||
{
|
||||
if (binEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var binPath = binEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(binPath))
|
||||
{
|
||||
AddBinEntrypoint(builder, pkgName, pkgName, binPath, packageJsonPath);
|
||||
}
|
||||
}
|
||||
else if (binEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var bin in binEl.EnumerateObject())
|
||||
{
|
||||
if (bin.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var binPath = bin.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(binPath))
|
||||
{
|
||||
AddBinEntrypoint(builder, pkgName, bin.Name, binPath, packageJsonPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddBinEntrypoint(
|
||||
ReachabilityGraphBuilder builder,
|
||||
string pkgName,
|
||||
string binName,
|
||||
string binPath,
|
||||
string packageJsonPath)
|
||||
{
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
var binSymbol = SymbolId.ForNode(pkgName, NormalizePath(binPath), "bin");
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: binSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "binary",
|
||||
display: $"{binName} -> {binPath}",
|
||||
sourceFile: NormalizePath(binPath),
|
||||
attributes: new Dictionary<string, string> { ["bin_name"] = binName });
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: binSymbol,
|
||||
edgeType: EdgeTypes.Spawn,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:bin.{binName}");
|
||||
}
|
||||
|
||||
private static void ProcessDependencies(
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement root,
|
||||
string pkgName,
|
||||
string depField,
|
||||
EdgeConfidence confidence)
|
||||
{
|
||||
if (!root.TryGetProperty(depField, out var depsEl) || depsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
|
||||
foreach (var dep in depsEl.EnumerateObject())
|
||||
{
|
||||
var depName = dep.Name;
|
||||
var depVersion = dep.Value.ValueKind == JsonValueKind.String ? dep.Value.GetString() : "*";
|
||||
|
||||
var depSymbol = SymbolId.ForNode(depName, string.Empty, "module");
|
||||
|
||||
// Add the dependency as a node (may already exist)
|
||||
builder.AddNode(
|
||||
symbolId: depSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "module",
|
||||
display: depName);
|
||||
|
||||
// Add edge from this package to the dependency
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: depSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: confidence,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"package.json:{depField}.{depName}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessSourceFilesAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jsFiles = Directory.EnumerateFiles(rootPath, "*.js", SearchOption.AllDirectories)
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.mjs", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.cjs", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.ts", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.tsx", SearchOption.AllDirectories))
|
||||
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Take(500); // Limit to prevent huge graphs
|
||||
|
||||
foreach (var filePath in jsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessSourceFileAsync(context, builder, filePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessSourceFileAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, filePath));
|
||||
|
||||
// Simple regex-based import extraction (Esprima is in the analyzer, not available here)
|
||||
ExtractImports(builder, relativePath, content);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File access issue, skip
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractImports(ReachabilityGraphBuilder builder, string sourceFile, string content)
|
||||
{
|
||||
var fileSymbol = SymbolId.ForNode(sourceFile, string.Empty, "file");
|
||||
builder.AddNode(
|
||||
symbolId: fileSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "file",
|
||||
display: sourceFile,
|
||||
sourceFile: sourceFile);
|
||||
|
||||
// Extract ES6 imports: import ... from '...'
|
||||
var importMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"import\s+(?:(?:\*\s+as\s+\w+)|(?:\{[^}]*\})|(?:\w+(?:\s*,\s*\{[^}]*\})?)|(?:type\s+\{[^}]*\}))\s+from\s+['""]([^'""]+)['""]",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in importMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "import", EdgeConfidence.Certain);
|
||||
}
|
||||
|
||||
// Extract require() calls: require('...')
|
||||
var requireMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"require\s*\(\s*['""]([^'""]+)['""]\s*\)",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in requireMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "require", EdgeConfidence.Certain);
|
||||
}
|
||||
|
||||
// Extract dynamic imports: import('...')
|
||||
var dynamicImportMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"import\s*\(\s*['""]([^'""]+)['""]\s*\)",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in dynamicImportMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "import()", EdgeConfidence.High);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddImportEdge(
|
||||
ReachabilityGraphBuilder builder,
|
||||
string fromSymbol,
|
||||
string sourceFile,
|
||||
string target,
|
||||
string kind,
|
||||
EdgeConfidence confidence)
|
||||
{
|
||||
// Determine target symbol
|
||||
string targetSymbol;
|
||||
if (target.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
// Relative import - resolve to file symbol
|
||||
targetSymbol = SymbolId.ForNode(target, string.Empty, "file");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Package import - resolve to module symbol
|
||||
var pkgName = GetPackageNameFromSpecifier(target);
|
||||
targetSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
}
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: targetSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: target.StartsWith(".", StringComparison.Ordinal) ? "file" : "module",
|
||||
display: target);
|
||||
|
||||
builder.AddEdge(
|
||||
from: fromSymbol,
|
||||
to: targetSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: confidence,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{sourceFile}:{kind}");
|
||||
}
|
||||
|
||||
private static string GetPackageNameFromSpecifier(string specifier)
|
||||
{
|
||||
// Handle scoped packages (@scope/pkg)
|
||||
if (specifier.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = specifier.Split('/', 3);
|
||||
return parts.Length >= 2 ? $"{parts[0]}/{parts[1]}" : specifier;
|
||||
}
|
||||
|
||||
// Regular package (pkg/subpath)
|
||||
var slashIndex = specifier.IndexOf('/');
|
||||
return slashIndex > 0 ? specifier[..slashIndex] : specifier;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string EncodePackageName(string name)
|
||||
{
|
||||
if (name.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
return "%40" + name[1..];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Registry and orchestrator for reachability lifters.
|
||||
/// Manages all available lifters and coordinates graph extraction.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLifterRegistry
|
||||
{
|
||||
private readonly IReadOnlyList<IReachabilityLifter> _lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a registry with the default set of lifters.
|
||||
/// </summary>
|
||||
public ReachabilityLifterRegistry()
|
||||
: this(GetDefaultLifters())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a registry with custom lifters.
|
||||
/// </summary>
|
||||
public ReachabilityLifterRegistry(IEnumerable<IReachabilityLifter> lifters)
|
||||
{
|
||||
_lifters = lifters
|
||||
.Where(l => l is not null)
|
||||
.OrderBy(l => l.Language, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered lifters.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReachabilityLifter> Lifters => _lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Gets lifters for the specified languages.
|
||||
/// </summary>
|
||||
public IEnumerable<IReachabilityLifter> GetLifters(params string[] languages)
|
||||
{
|
||||
if (languages is null or { Length: 0 })
|
||||
{
|
||||
return _lifters;
|
||||
}
|
||||
|
||||
var langSet = new HashSet<string>(languages, StringComparer.OrdinalIgnoreCase);
|
||||
return _lifters.Where(l => langSet.Contains(l.Language));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all lifters against the context and returns a combined graph.
|
||||
/// </summary>
|
||||
public async ValueTask<ReachabilityUnionGraph> LiftAllAsync(
|
||||
ReachabilityLifterContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
foreach (var lifter in _lifters)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await lifter.LiftAsync(context, builder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Use a combined language identifier for multi-language graphs
|
||||
var languages = _lifters.Select(l => l.Language).Distinct().OrderBy(l => l, StringComparer.Ordinal);
|
||||
var combinedLang = string.Join("+", languages);
|
||||
|
||||
return builder.ToUnionGraph(combinedLang);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs specific lifters by language and returns a combined graph.
|
||||
/// </summary>
|
||||
public async ValueTask<ReachabilityUnionGraph> LiftAsync(
|
||||
ReachabilityLifterContext context,
|
||||
IEnumerable<string> languages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
var lifters = GetLifters(languages?.ToArray() ?? Array.Empty<string>()).ToList();
|
||||
|
||||
foreach (var lifter in lifters)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await lifter.LiftAsync(context, builder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var combinedLang = string.Join("+", lifters.Select(l => l.Language).Distinct().OrderBy(l => l, StringComparer.Ordinal));
|
||||
return builder.ToUnionGraph(combinedLang);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all lifters and writes the result using the union writer.
|
||||
/// </summary>
|
||||
public async ValueTask<ReachabilityUnionWriteResult> LiftAndWriteAsync(
|
||||
ReachabilityLifterContext context,
|
||||
string outputRoot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
|
||||
|
||||
var graph = await LiftAllAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var writer = new ReachabilityUnionWriter();
|
||||
return await writer.WriteAsync(graph, outputRoot, context.AnalysisId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default set of lifters for all supported languages.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<IReachabilityLifter> GetDefaultLifters()
|
||||
{
|
||||
return new IReachabilityLifter[]
|
||||
{
|
||||
new NodeReachabilityLifter(),
|
||||
new DotNetReachabilityLifter(),
|
||||
// Future lifters:
|
||||
// new GoReachabilityLifter(),
|
||||
// new RustReachabilityLifter(),
|
||||
// new JavaReachabilityLifter(),
|
||||
// new PythonReachabilityLifter(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Builds reachability graphs with full schema support including
|
||||
/// rich node metadata, confidence levels, and source provenance.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityGraphBuilder
|
||||
{
|
||||
private const string GraphSchemaVersion = "1.0";
|
||||
private readonly Dictionary<string, RichNode> _richNodes = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<RichEdge> _richEdges = new();
|
||||
|
||||
// Legacy compatibility
|
||||
private readonly HashSet<string> nodes = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<ReachabilityEdge> edges = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a simple node (legacy API).
|
||||
/// </summary>
|
||||
public ReachabilityGraphBuilder AddNode(string symbolId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(symbolId))
|
||||
@@ -22,6 +34,41 @@ public sealed class ReachabilityGraphBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rich node with full metadata.
|
||||
/// </summary>
|
||||
public ReachabilityGraphBuilder AddNode(
|
||||
string symbolId,
|
||||
string lang,
|
||||
string kind,
|
||||
string? display = null,
|
||||
string? sourceFile = null,
|
||||
int? sourceLine = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolId))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var id = symbolId.Trim();
|
||||
var node = new RichNode(
|
||||
id,
|
||||
lang?.Trim() ?? string.Empty,
|
||||
kind?.Trim() ?? "symbol",
|
||||
display?.Trim(),
|
||||
sourceFile?.Trim(),
|
||||
sourceLine,
|
||||
attributes?.ToImmutableSortedDictionary(StringComparer.Ordinal) ?? ImmutableSortedDictionary<string, string>.Empty);
|
||||
|
||||
_richNodes[id] = node;
|
||||
nodes.Add(id);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a simple edge (legacy API).
|
||||
/// </summary>
|
||||
public ReachabilityGraphBuilder AddEdge(string from, string to, string kind = "call")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
|
||||
@@ -36,6 +83,52 @@ public sealed class ReachabilityGraphBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rich edge with confidence and provenance.
|
||||
/// </summary>
|
||||
/// <param name="from">Source symbol ID.</param>
|
||||
/// <param name="to">Target symbol ID.</param>
|
||||
/// <param name="edgeType">Edge type: call, import, inherits, loads, dynamic, reflects, dlopen, ffi, wasm, spawn.</param>
|
||||
/// <param name="confidence">Confidence level: certain, high, medium, low.</param>
|
||||
/// <param name="origin">Origin: static or runtime.</param>
|
||||
/// <param name="provenance">Provenance hint: jvm-bytecode, il, ts-ast, ssa, ebpf, etw, jfr, hook.</param>
|
||||
/// <param name="evidence">Evidence locator (e.g., "file:path:line").</param>
|
||||
public ReachabilityGraphBuilder AddEdge(
|
||||
string from,
|
||||
string to,
|
||||
string edgeType,
|
||||
EdgeConfidence confidence,
|
||||
string origin = "static",
|
||||
string? provenance = null,
|
||||
string? evidence = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var fromId = from.Trim();
|
||||
var toId = to.Trim();
|
||||
var type = string.IsNullOrWhiteSpace(edgeType) ? "call" : edgeType.Trim();
|
||||
|
||||
var richEdge = new RichEdge(
|
||||
fromId,
|
||||
toId,
|
||||
type,
|
||||
confidence,
|
||||
origin?.Trim() ?? "static",
|
||||
provenance?.Trim(),
|
||||
evidence?.Trim());
|
||||
|
||||
_richEdges.Add(richEdge);
|
||||
nodes.Add(fromId);
|
||||
nodes.Add(toId);
|
||||
|
||||
// Also add to legacy set for compatibility
|
||||
edges.Add(new ReachabilityEdge(fromId, toId, type));
|
||||
return this;
|
||||
}
|
||||
|
||||
public string BuildJson(bool indented = true)
|
||||
{
|
||||
var payload = new ReachabilityGraphPayload
|
||||
@@ -54,21 +147,102 @@ public sealed class ReachabilityGraphBuilder
|
||||
return JsonSerializer.Serialize(payload, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the builder contents to a union graph using rich metadata when available.
|
||||
/// </summary>
|
||||
public ReachabilityUnionGraph ToUnionGraph(string language)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
var nodeList = nodes
|
||||
.Select(id => new ReachabilityUnionNode(id, language, "symbol"))
|
||||
.ToList();
|
||||
var lang = language.Trim();
|
||||
|
||||
var edgeList = edges
|
||||
.Select(edge => new ReachabilityUnionEdge(edge.From, edge.To, edge.Kind))
|
||||
.ToList();
|
||||
// Build nodes: prefer rich metadata, fall back to simple nodes
|
||||
var nodeList = new List<ReachabilityUnionNode>();
|
||||
foreach (var id in nodes.OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
if (_richNodes.TryGetValue(id, out var rich))
|
||||
{
|
||||
var source = rich.SourceFile is not null
|
||||
? new ReachabilitySource("static", null, rich.SourceLine.HasValue ? $"file:{rich.SourceFile}:{rich.SourceLine}" : $"file:{rich.SourceFile}")
|
||||
: null;
|
||||
|
||||
nodeList.Add(new ReachabilityUnionNode(
|
||||
id,
|
||||
rich.Lang,
|
||||
rich.Kind,
|
||||
rich.Display,
|
||||
source,
|
||||
rich.Attributes.Count > 0 ? rich.Attributes : null));
|
||||
}
|
||||
else
|
||||
{
|
||||
nodeList.Add(new ReachabilityUnionNode(id, lang, "symbol"));
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges: prefer rich metadata, fall back to simple edges
|
||||
var edgeSet = new HashSet<(string, string, string)>();
|
||||
var edgeList = new List<ReachabilityUnionEdge>();
|
||||
|
||||
foreach (var rich in _richEdges.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.EdgeType, StringComparer.Ordinal))
|
||||
{
|
||||
var key = (rich.From, rich.To, rich.EdgeType);
|
||||
if (!edgeSet.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = new ReachabilitySource(
|
||||
rich.Origin,
|
||||
rich.Provenance,
|
||||
rich.Evidence);
|
||||
|
||||
edgeList.Add(new ReachabilityUnionEdge(
|
||||
rich.From,
|
||||
rich.To,
|
||||
rich.EdgeType,
|
||||
ConfidenceToString(rich.Confidence),
|
||||
source));
|
||||
}
|
||||
|
||||
// Add any legacy edges not already covered
|
||||
foreach (var edge in edges.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal))
|
||||
{
|
||||
var key = (edge.From, edge.To, edge.Kind);
|
||||
if (!edgeSet.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
edgeList.Add(new ReachabilityUnionEdge(edge.From, edge.To, edge.Kind));
|
||||
}
|
||||
|
||||
return new ReachabilityUnionGraph(nodeList, edgeList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of nodes in the graph.
|
||||
/// </summary>
|
||||
public int NodeCount => nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of edges in the graph.
|
||||
/// </summary>
|
||||
public int EdgeCount => edges.Count + _richEdges.Count(re => !edges.Contains(new ReachabilityEdge(re.From, re.To, re.EdgeType)));
|
||||
|
||||
private static string ConfidenceToString(EdgeConfidence confidence) => confidence switch
|
||||
{
|
||||
EdgeConfidence.Certain => "certain",
|
||||
EdgeConfidence.High => "high",
|
||||
EdgeConfidence.Medium => "medium",
|
||||
EdgeConfidence.Low => "low",
|
||||
_ => "certain"
|
||||
};
|
||||
|
||||
public static ReachabilityGraphBuilder FromFixture(string variantPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(variantPath);
|
||||
@@ -133,4 +307,80 @@ public sealed class ReachabilityGraphBuilder
|
||||
public List<ReachabilityNode> Nodes { get; set; } = new();
|
||||
public List<ReachabilityEdgePayload> Edges { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed record RichNode(
|
||||
string SymbolId,
|
||||
string Lang,
|
||||
string Kind,
|
||||
string? Display,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
ImmutableSortedDictionary<string, string> Attributes);
|
||||
|
||||
private sealed record RichEdge(
|
||||
string From,
|
||||
string To,
|
||||
string EdgeType,
|
||||
EdgeConfidence Confidence,
|
||||
string Origin,
|
||||
string? Provenance,
|
||||
string? Evidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence levels for reachability edges per the union schema.
|
||||
/// </summary>
|
||||
public enum EdgeConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge is certain (direct call, import statement).
|
||||
/// </summary>
|
||||
Certain,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence (type-constrained virtual call).
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence (interface dispatch, some dynamic patterns).
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// Low confidence (reflection, string-based loading).
|
||||
/// </summary>
|
||||
Low
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known edge types per the reachability union schema.
|
||||
/// </summary>
|
||||
public static class EdgeTypes
|
||||
{
|
||||
public const string Call = "call";
|
||||
public const string Import = "import";
|
||||
public const string Inherits = "inherits";
|
||||
public const string Loads = "loads";
|
||||
public const string Dynamic = "dynamic";
|
||||
public const string Reflects = "reflects";
|
||||
public const string Dlopen = "dlopen";
|
||||
public const string Ffi = "ffi";
|
||||
public const string Wasm = "wasm";
|
||||
public const string Spawn = "spawn";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known provenance hints per the reachability union schema.
|
||||
/// </summary>
|
||||
public static class Provenance
|
||||
{
|
||||
public const string JvmBytecode = "jvm-bytecode";
|
||||
public const string Il = "il";
|
||||
public const string TsAst = "ts-ast";
|
||||
public const string Ssa = "ssa";
|
||||
public const string Ebpf = "ebpf";
|
||||
public const string Etw = "etw";
|
||||
public const string Jfr = "jfr";
|
||||
public const string Hook = "hook";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Builds canonical SymbolIDs per the reachability union schema (v0.1).
|
||||
/// SymbolIDs are stable, path-independent identifiers that enable CAS lookups
|
||||
/// to remain reproducible and cacheable across hosts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: <c>sym:{lang}:{stable-fragment}</c>
|
||||
/// where stable-fragment is SHA-256(base64url-no-pad) of the canonical tuple per language.
|
||||
/// </remarks>
|
||||
public static class SymbolId
|
||||
{
|
||||
/// <summary>
|
||||
/// Supported languages for symbol IDs.
|
||||
/// </summary>
|
||||
public static class Lang
|
||||
{
|
||||
public const string Java = "java";
|
||||
public const string DotNet = "dotnet";
|
||||
public const string Go = "go";
|
||||
public const string Node = "node";
|
||||
public const string Deno = "deno";
|
||||
public const string Rust = "rust";
|
||||
public const string Swift = "swift";
|
||||
public const string Shell = "shell";
|
||||
public const string Binary = "binary";
|
||||
public const string Python = "python";
|
||||
public const string Ruby = "ruby";
|
||||
public const string Php = "php";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Java symbol ID from method signature components.
|
||||
/// </summary>
|
||||
/// <param name="package">Package name (e.g., "com.example").</param>
|
||||
/// <param name="className">Class name (e.g., "MyClass").</param>
|
||||
/// <param name="method">Method name (e.g., "doSomething").</param>
|
||||
/// <param name="descriptor">JVM method descriptor (e.g., "(Ljava/lang/String;)V").</param>
|
||||
public static string ForJava(string package, string className, string method, string descriptor)
|
||||
{
|
||||
var tuple = $"{Lower(package)}\0{Lower(className)}\0{Lower(method)}\0{Lower(descriptor)}";
|
||||
return Build(Lang.Java, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a .NET symbol ID from member signature components.
|
||||
/// </summary>
|
||||
/// <param name="assemblyName">Assembly name (without version/key).</param>
|
||||
/// <param name="ns">Namespace.</param>
|
||||
/// <param name="typeName">Type name.</param>
|
||||
/// <param name="memberSignature">Member signature using ECMA-335 format.</param>
|
||||
public static string ForDotNet(string assemblyName, string ns, string typeName, string memberSignature)
|
||||
{
|
||||
var tuple = $"{Norm(assemblyName)}\0{Norm(ns)}\0{Norm(typeName)}\0{Norm(memberSignature)}";
|
||||
return Build(Lang.DotNet, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Node/Deno symbol ID from module export components.
|
||||
/// </summary>
|
||||
/// <param name="pkgNameOrPath">npm package name or normalized absolute path (drive stripped).</param>
|
||||
/// <param name="exportPath">ESM/CJS export path (slash-joined).</param>
|
||||
/// <param name="kind">Export kind (e.g., "function", "class", "default").</param>
|
||||
public static string ForNode(string pkgNameOrPath, string exportPath, string kind)
|
||||
{
|
||||
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
|
||||
return Build(Lang.Node, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Deno symbol ID from module export components.
|
||||
/// </summary>
|
||||
public static string ForDeno(string pkgNameOrPath, string exportPath, string kind)
|
||||
{
|
||||
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
|
||||
return Build(Lang.Deno, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Go symbol ID from function/method components.
|
||||
/// </summary>
|
||||
/// <param name="modulePath">Module path (e.g., "github.com/example/repo").</param>
|
||||
/// <param name="packagePath">Package path within module.</param>
|
||||
/// <param name="receiver">Receiver type (empty for functions).</param>
|
||||
/// <param name="func">Function name.</param>
|
||||
public static string ForGo(string modulePath, string packagePath, string receiver, string func)
|
||||
{
|
||||
var tuple = $"{Norm(modulePath)}\0{Norm(packagePath)}\0{Norm(receiver)}\0{Norm(func)}";
|
||||
return Build(Lang.Go, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Rust symbol ID from item components.
|
||||
/// </summary>
|
||||
/// <param name="crateName">Crate name.</param>
|
||||
/// <param name="modulePath">Module path within crate (e.g., "foo::bar").</param>
|
||||
/// <param name="itemName">Item name (function, struct, trait, etc.).</param>
|
||||
/// <param name="mangled">Optional Rust-mangled name.</param>
|
||||
public static string ForRust(string crateName, string modulePath, string itemName, string? mangled = null)
|
||||
{
|
||||
var tuple = $"{Norm(crateName)}\0{Norm(modulePath)}\0{Norm(itemName)}\0{Norm(mangled)}";
|
||||
return Build(Lang.Rust, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Swift symbol ID from member components.
|
||||
/// </summary>
|
||||
/// <param name="module">Swift module name.</param>
|
||||
/// <param name="typeName">Type name (class, struct, enum, protocol).</param>
|
||||
/// <param name="member">Member name.</param>
|
||||
/// <param name="mangled">Optional Swift-mangled name.</param>
|
||||
public static string ForSwift(string module, string typeName, string member, string? mangled = null)
|
||||
{
|
||||
var tuple = $"{Norm(module)}\0{Norm(typeName)}\0{Norm(member)}\0{Norm(mangled)}";
|
||||
return Build(Lang.Swift, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shell symbol ID from script/function components.
|
||||
/// </summary>
|
||||
/// <param name="scriptRelPath">Relative path to script file.</param>
|
||||
/// <param name="functionOrCmd">Function name or command identifier.</param>
|
||||
public static string ForShell(string scriptRelPath, string functionOrCmd)
|
||||
{
|
||||
var tuple = $"{Norm(scriptRelPath)}\0{Norm(functionOrCmd)}";
|
||||
return Build(Lang.Shell, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary symbol ID from ELF/PE/Mach-O components.
|
||||
/// </summary>
|
||||
/// <param name="buildId">Binary build-id (GNU build-id, PE GUID, Mach-O UUID).</param>
|
||||
/// <param name="section">Section name (e.g., ".text", ".dynsym").</param>
|
||||
/// <param name="symbolName">Symbol name from symbol table.</param>
|
||||
public static string ForBinary(string buildId, string section, string symbolName)
|
||||
{
|
||||
var tuple = $"{Norm(buildId)}\0{Norm(section)}\0{Norm(symbolName)}";
|
||||
return Build(Lang.Binary, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Python symbol ID from module/function components.
|
||||
/// </summary>
|
||||
/// <param name="packageOrPath">Package name or module file path.</param>
|
||||
/// <param name="modulePath">Module path within package (dot-separated).</param>
|
||||
/// <param name="qualifiedName">Qualified name (class.method or function).</param>
|
||||
public static string ForPython(string packageOrPath, string modulePath, string qualifiedName)
|
||||
{
|
||||
var tuple = $"{Norm(packageOrPath)}\0{Norm(modulePath)}\0{Norm(qualifiedName)}";
|
||||
return Build(Lang.Python, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Ruby symbol ID from module/method components.
|
||||
/// </summary>
|
||||
/// <param name="gemOrPath">Gem name or file path.</param>
|
||||
/// <param name="modulePath">Module/class path (e.g., "Foo::Bar").</param>
|
||||
/// <param name="methodName">Method name (with prefix # for instance, . for class).</param>
|
||||
public static string ForRuby(string gemOrPath, string modulePath, string methodName)
|
||||
{
|
||||
var tuple = $"{Norm(gemOrPath)}\0{Norm(modulePath)}\0{Norm(methodName)}";
|
||||
return Build(Lang.Ruby, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PHP symbol ID from namespace/function components.
|
||||
/// </summary>
|
||||
/// <param name="composerPackage">Composer package name or file path.</param>
|
||||
/// <param name="ns">Namespace (e.g., "App\\Services").</param>
|
||||
/// <param name="qualifiedName">Fully qualified class::method or function name.</param>
|
||||
public static string ForPhp(string composerPackage, string ns, string qualifiedName)
|
||||
{
|
||||
var tuple = $"{Norm(composerPackage)}\0{Norm(ns)}\0{Norm(qualifiedName)}";
|
||||
return Build(Lang.Php, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a symbol ID from a pre-computed canonical tuple and language.
|
||||
/// </summary>
|
||||
/// <param name="lang">Language identifier (use <see cref="Lang"/> constants).</param>
|
||||
/// <param name="canonicalTuple">Pre-formatted canonical tuple (NUL-separated components).</param>
|
||||
public static string FromTuple(string lang, string canonicalTuple)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lang);
|
||||
return Build(lang, canonicalTuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a symbol ID into its language and fragment components.
|
||||
/// </summary>
|
||||
/// <returns>Tuple of (language, fragment) or null if invalid format.</returns>
|
||||
public static (string Lang, string Fragment)? Parse(string symbolId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolId) || !symbolId.StartsWith("sym:", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rest = symbolId.AsSpan(4); // Skip "sym:"
|
||||
var colonIndex = rest.IndexOf(':');
|
||||
if (colonIndex < 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lang = rest[..colonIndex].ToString();
|
||||
var fragment = rest[(colonIndex + 1)..].ToString();
|
||||
return (lang, fragment);
|
||||
}
|
||||
|
||||
private static string Build(string lang, string tuple)
|
||||
{
|
||||
var hash = ComputeFragment(tuple);
|
||||
return $"sym:{lang}:{hash}";
|
||||
}
|
||||
|
||||
private static string ComputeFragment(string tuple)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(tuple);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
// Base64url without padding per spec
|
||||
return Convert.ToBase64String(hash)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private static string Lower(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string Norm(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
|
||||
}
|
||||
Reference in New Issue
Block a user