Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# StellaOps.Scanner.EntryTrace — Agent Charter
|
||||
|
||||
## Mission
|
||||
Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that fuel usage-aware SBOMs, policy explainability, and runtime drift detection. Implement the EntryTrace analyzers and expose them as restart-time plug-ins for the Scanner Worker.
|
||||
|
||||
## Scope
|
||||
- Parse POSIX/Bourne shell constructs (exec, command, case, if, source/run-parts) with deterministic AST output.
|
||||
- Walk layered root filesystems to resolve PATH lookups, interpreter hand-offs (Python/Node/Java), and record evidence.
|
||||
- Surface explainable diagnostics for unresolved branches (env indirection, missing files, unsupported syntax) and emit metrics.
|
||||
- Package analyzers as signed plug-ins under `plugins/scanner/entrytrace/`, guarded by restart-only policy.
|
||||
|
||||
## Out of Scope
|
||||
- SBOM emission/diffing (owned by `Scanner.Emit`/`Scanner.Diff`).
|
||||
- Runtime enforcement or live drift reconciliation (owned by Zastava).
|
||||
- Registry/network fetchers beyond file lookups inside extracted layers.
|
||||
|
||||
## Interfaces & Contracts
|
||||
- Primary entry point: `IEntryTraceAnalyzer.ResolveAsync` returning a deterministic `EntryTraceGraph`.
|
||||
- Graph nodes must include file path, line span, interpreter classification, evidence source, and follow `Scanner.Core` timestamp/ID helpers when emitting events.
|
||||
- Diagnostics must enumerate unknown reasons from fixed enum; metrics tagged `entrytrace.*`.
|
||||
- Plug-ins register via `IEntryTraceAnalyzerFactory` and must validate against `IPluginCatalogGuard`.
|
||||
|
||||
## Observability & Security
|
||||
- No dynamic assembly loading beyond restart-time plug-in catalog.
|
||||
- Structured logs include `scanId`, `imageDigest`, `layerDigest`, `command`, `reason`.
|
||||
- Metrics counters: `entrytrace_resolutions_total{result}`, `entrytrace_unresolved_total{reason}`.
|
||||
- Deny `source` directives outside image root; sandbox file IO via provided `IRootFileSystem`.
|
||||
|
||||
## Testing
|
||||
- Unit tests live in `../StellaOps.Scanner.EntryTrace.Tests` with golden fixtures under `Fixtures/`.
|
||||
- Determinism harness: same inputs produce byte-identical serialized graphs.
|
||||
- Parser fuzz seeds captured for regression; interpreter tracers validated with sample scripts for Python, Node, Java launchers.
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
|
||||
public static class EntryTraceInstrumentation
|
||||
{
|
||||
public static readonly Meter Meter = new("stellaops.scanner.entrytrace", "1.0.0");
|
||||
}
|
||||
|
||||
public sealed class EntryTraceMetrics
|
||||
{
|
||||
private readonly Counter<long> _resolutions;
|
||||
private readonly Counter<long> _unresolved;
|
||||
|
||||
public EntryTraceMetrics()
|
||||
{
|
||||
_resolutions = EntryTraceInstrumentation.Meter.CreateCounter<long>(
|
||||
"entrytrace_resolutions_total",
|
||||
description: "Number of entry trace attempts by outcome.");
|
||||
_unresolved = EntryTraceInstrumentation.Meter.CreateCounter<long>(
|
||||
"entrytrace_unresolved_total",
|
||||
description: "Number of unresolved entry trace hops by reason.");
|
||||
}
|
||||
|
||||
public void RecordOutcome(string imageDigest, string scanId, EntryTraceOutcome outcome)
|
||||
{
|
||||
_resolutions.Add(1, CreateTags(imageDigest, scanId, ("outcome", outcome.ToString().ToLowerInvariant())));
|
||||
}
|
||||
|
||||
public void RecordUnknown(string imageDigest, string scanId, EntryTraceUnknownReason reason)
|
||||
{
|
||||
_unresolved.Add(1, CreateTags(imageDigest, scanId, ("reason", reason.ToString().ToLowerInvariant())));
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] CreateTags(string imageDigest, string scanId, params (string Key, object? Value)[] extras)
|
||||
{
|
||||
var tags = new List<KeyValuePair<string, object?>>(2 + extras.Length)
|
||||
{
|
||||
new("image", imageDigest),
|
||||
new("scan.id", scanId)
|
||||
};
|
||||
|
||||
foreach (var extra in extras)
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>(extra.Key, extra.Value));
|
||||
}
|
||||
|
||||
return tags.ToArray();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public sealed class EntryTraceAnalyzerOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:Analyzers:EntryTrace";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum recursion depth while following includes/run-parts/interpreters.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; set; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Enables traversal of run-parts directories.
|
||||
/// </summary>
|
||||
public bool FollowRunParts { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Colon-separated default PATH string used when the environment omits PATH.
|
||||
/// </summary>
|
||||
public string DefaultPath { get; set; } = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of scripts considered per run-parts directory to prevent explosion.
|
||||
/// </summary>
|
||||
public int RunPartsLimit { get; set; } = 64;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Provides runtime context for entry trace analysis.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceContext(
|
||||
IRootFileSystem FileSystem,
|
||||
ImmutableDictionary<string, string> Environment,
|
||||
ImmutableArray<string> Path,
|
||||
string WorkingDirectory,
|
||||
string User,
|
||||
string ImageDigest,
|
||||
string ScanId,
|
||||
ILogger? Logger);
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Combines OCI configuration and root filesystem data into the context required by the EntryTrace analyzer.
|
||||
/// </summary>
|
||||
public static class EntryTraceImageContextFactory
|
||||
{
|
||||
private const string DefaultUser = "root";
|
||||
|
||||
public static EntryTraceImageContext Create(
|
||||
OciImageConfig config,
|
||||
IRootFileSystem fileSystem,
|
||||
EntryTraceAnalyzerOptions options,
|
||||
string imageDigest,
|
||||
string scanId,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var environment = BuildEnvironment(config.Environment);
|
||||
var path = DeterminePath(environment, options);
|
||||
var workingDir = NormalizeWorkingDirectory(config.WorkingDirectory);
|
||||
var user = NormalizeUser(config.User);
|
||||
|
||||
var context = new EntryTraceContext(
|
||||
fileSystem,
|
||||
environment,
|
||||
path,
|
||||
workingDir,
|
||||
user,
|
||||
imageDigest,
|
||||
scanId,
|
||||
logger);
|
||||
|
||||
var entrypoint = EntrypointSpecification.FromExecForm(
|
||||
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
|
||||
config.Command.IsDefaultOrEmpty ? null : config.Command);
|
||||
|
||||
return new EntryTraceImageContext(entrypoint, context);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildEnvironment(ImmutableArray<string> raw)
|
||||
{
|
||||
if (raw.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var entry in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separatorIndex = entry.IndexOf('=');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
var key = entry.Trim();
|
||||
if (key.Length > 0)
|
||||
{
|
||||
dictionary[key] = string.Empty;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var keyPart = entry[..separatorIndex].Trim();
|
||||
if (keyPart.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var valuePart = entry[(separatorIndex + 1)..];
|
||||
dictionary[keyPart] = valuePart;
|
||||
}
|
||||
|
||||
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, dictionary);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> DeterminePath(ImmutableDictionary<string, string> env, EntryTraceAnalyzerOptions options)
|
||||
{
|
||||
if (env.TryGetValue("PATH", out var pathValue) && !string.IsNullOrWhiteSpace(pathValue))
|
||||
{
|
||||
return SplitPath(pathValue);
|
||||
}
|
||||
|
||||
var fallback = string.IsNullOrWhiteSpace(options.DefaultPath)
|
||||
? EntryTraceDefaults.DefaultPath
|
||||
: options.DefaultPath;
|
||||
|
||||
return SplitPath(fallback);
|
||||
}
|
||||
|
||||
private static string NormalizeWorkingDirectory(string? workingDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workingDir))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
var text = workingDir.Replace('\\', '/').Trim();
|
||||
if (!text.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
text = "/" + text;
|
||||
}
|
||||
|
||||
if (text.Length > 1 && text.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
text = text.TrimEnd('/');
|
||||
}
|
||||
|
||||
return text.Length == 0 ? "/" : text;
|
||||
}
|
||||
|
||||
private static string NormalizeUser(string? user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user))
|
||||
{
|
||||
return DefaultUser;
|
||||
}
|
||||
|
||||
return user.Trim();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> SplitPath(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
foreach (var segment in value.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (segment.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = segment.Replace('\\', '/');
|
||||
if (!normalized.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
|
||||
if (normalized.EndsWith("/", StringComparison.Ordinal) && normalized.Length > 1)
|
||||
{
|
||||
normalized = normalized.TrimEnd('/');
|
||||
}
|
||||
|
||||
builder.Add(normalized);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundles the resolved entrypoint and context required for the analyzer to operate.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceImageContext(
|
||||
EntrypointSpecification Entrypoint,
|
||||
EntryTraceContext Context);
|
||||
|
||||
internal static class EntryTraceDefaults
|
||||
{
|
||||
public const string DefaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome classification for entrypoint resolution attempts.
|
||||
/// </summary>
|
||||
public enum EntryTraceOutcome
|
||||
{
|
||||
Resolved,
|
||||
PartiallyResolved,
|
||||
Unresolved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical classification for nodes in the entry trace graph.
|
||||
/// </summary>
|
||||
public enum EntryTraceNodeKind
|
||||
{
|
||||
Command,
|
||||
Script,
|
||||
Include,
|
||||
Interpreter,
|
||||
Executable,
|
||||
RunPartsDirectory,
|
||||
RunPartsScript
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpreter categories supported by the analyzer.
|
||||
/// </summary>
|
||||
public enum EntryTraceInterpreterKind
|
||||
{
|
||||
None,
|
||||
Python,
|
||||
Node,
|
||||
Java
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic severity levels emitted by the analyzer.
|
||||
/// </summary>
|
||||
public enum EntryTraceDiagnosticSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the canonical reasons for unresolved edges.
|
||||
/// </summary>
|
||||
public enum EntryTraceUnknownReason
|
||||
{
|
||||
CommandNotFound,
|
||||
MissingFile,
|
||||
DynamicEnvironmentReference,
|
||||
UnsupportedSyntax,
|
||||
RecursionLimitReached,
|
||||
InterpreterNotSupported,
|
||||
ModuleNotFound,
|
||||
JarNotFound,
|
||||
RunPartsEmpty,
|
||||
PermissionDenied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span within a script file.
|
||||
/// </summary>
|
||||
public readonly record struct EntryTraceSpan(
|
||||
string? Path,
|
||||
int StartLine,
|
||||
int StartColumn,
|
||||
int EndLine,
|
||||
int EndColumn);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence describing where a node originated from within the image.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceEvidence(
|
||||
string Path,
|
||||
string? LayerDigest,
|
||||
string Source,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the entry trace graph.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceNode(
|
||||
int Id,
|
||||
EntryTraceNodeKind Kind,
|
||||
string DisplayName,
|
||||
ImmutableArray<string> Arguments,
|
||||
EntryTraceInterpreterKind InterpreterKind,
|
||||
EntryTraceEvidence? Evidence,
|
||||
EntryTraceSpan? Span);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a directed edge in the entry trace graph.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceEdge(
|
||||
int FromNodeId,
|
||||
int ToNodeId,
|
||||
string Relationship,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Captures diagnostic information regarding resolution gaps.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity Severity,
|
||||
EntryTraceUnknownReason Reason,
|
||||
string Message,
|
||||
EntryTraceSpan? Span,
|
||||
string? RelatedPath);
|
||||
|
||||
/// <summary>
|
||||
/// Final graph output produced by the analyzer.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceGraph(
|
||||
EntryTraceOutcome Outcome,
|
||||
ImmutableArray<EntryTraceNode> Nodes,
|
||||
ImmutableArray<EntryTraceEdge> Edges,
|
||||
ImmutableArray<EntryTraceDiagnostic> Diagnostics);
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the combined Docker ENTRYPOINT/CMD contract provided to the analyzer.
|
||||
/// </summary>
|
||||
public sealed record EntrypointSpecification
|
||||
{
|
||||
private EntrypointSpecification(
|
||||
ImmutableArray<string> entrypoint,
|
||||
ImmutableArray<string> command,
|
||||
string? entrypointShell,
|
||||
string? commandShell)
|
||||
{
|
||||
Entrypoint = entrypoint;
|
||||
Command = command;
|
||||
EntrypointShell = string.IsNullOrWhiteSpace(entrypointShell) ? null : entrypointShell;
|
||||
CommandShell = string.IsNullOrWhiteSpace(commandShell) ? null : commandShell;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exec-form ENTRYPOINT arguments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Entrypoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Exec-form CMD arguments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Shell-form ENTRYPOINT (if provided).
|
||||
/// </summary>
|
||||
public string? EntrypointShell { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Shell-form CMD (if provided).
|
||||
/// </summary>
|
||||
public string? CommandShell { get; }
|
||||
|
||||
public static EntrypointSpecification FromExecForm(
|
||||
IEnumerable<string>? entrypoint,
|
||||
IEnumerable<string>? command)
|
||||
=> new(
|
||||
entrypoint is null ? ImmutableArray<string>.Empty : entrypoint.ToImmutableArray(),
|
||||
command is null ? ImmutableArray<string>.Empty : command.ToImmutableArray(),
|
||||
entrypointShell: null,
|
||||
commandShell: null);
|
||||
|
||||
public static EntrypointSpecification FromShellForm(
|
||||
string? entrypoint,
|
||||
string? command)
|
||||
=> new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
entrypoint,
|
||||
command);
|
||||
|
||||
public EntrypointSpecification WithCommand(IEnumerable<string>? command)
|
||||
=> new(Entrypoint, command?.ToImmutableArray() ?? ImmutableArray<string>.Empty, EntrypointShell, CommandShell);
|
||||
|
||||
public EntrypointSpecification WithCommandShell(string? commandShell)
|
||||
=> new(Entrypoint, Command, EntrypointShell, commandShell);
|
||||
|
||||
public EntrypointSpecification WithEntrypoint(IEnumerable<string>? entrypoint)
|
||||
=> new(entrypoint?.ToImmutableArray() ?? ImmutableArray<string>.Empty, Command, EntrypointShell, CommandShell);
|
||||
|
||||
public EntrypointSpecification WithEntrypointShell(string? entrypointShell)
|
||||
=> new(Entrypoint, Command, entrypointShell, CommandShell);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a layered read-only filesystem snapshot built from container layers.
|
||||
/// </summary>
|
||||
public interface IRootFileSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to resolve an executable by name using the provided PATH entries.
|
||||
/// </summary>
|
||||
bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the contents of a file as UTF-8 text.
|
||||
/// </summary>
|
||||
bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content);
|
||||
|
||||
/// <summary>
|
||||
/// Returns descriptors for entries contained within a directory.
|
||||
/// </summary>
|
||||
ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a directory exists.
|
||||
/// </summary>
|
||||
bool DirectoryExists(string path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a file discovered within the layered filesystem.
|
||||
/// </summary>
|
||||
public sealed record RootFileDescriptor(
|
||||
string Path,
|
||||
string? LayerDigest,
|
||||
bool IsExecutable,
|
||||
bool IsDirectory,
|
||||
string? ShebangInterpreter);
|
||||
@@ -0,0 +1,771 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using IOPath = System.IO.Path;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an <see cref="IRootFileSystem"/> backed by OCI image layers.
|
||||
/// </summary>
|
||||
public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
{
|
||||
private const int MaxSymlinkDepth = 32;
|
||||
private const int MaxCachedTextBytes = 1_048_576; // 1 MiB
|
||||
|
||||
private readonly ImmutableDictionary<string, FileEntry> _entries;
|
||||
|
||||
private LayeredRootFileSystem(IDictionary<string, FileEntry> entries)
|
||||
{
|
||||
_entries = entries.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a directory on disk containing a single layer's contents.
|
||||
/// </summary>
|
||||
public sealed record LayerDirectory(string Digest, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// Describes a tar archive on disk containing a single layer's contents.
|
||||
/// </summary>
|
||||
public sealed record LayerArchive(string Digest, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a root filesystem by applying the provided directory layers in order.
|
||||
/// </summary>
|
||||
public static LayeredRootFileSystem FromDirectories(IEnumerable<LayerDirectory> layers)
|
||||
{
|
||||
if (layers is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(layers));
|
||||
}
|
||||
|
||||
var builder = new Builder();
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
builder.ApplyDirectoryLayer(layer);
|
||||
}
|
||||
|
||||
return new LayeredRootFileSystem(builder.Build());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a root filesystem by applying the provided tar archive layers in order.
|
||||
/// </summary>
|
||||
public static LayeredRootFileSystem FromArchives(IEnumerable<LayerArchive> layers)
|
||||
{
|
||||
if (layers is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(layers));
|
||||
}
|
||||
|
||||
var builder = new Builder();
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
builder.ApplyArchiveLayer(layer);
|
||||
}
|
||||
|
||||
return new LayeredRootFileSystem(builder.Build());
|
||||
}
|
||||
|
||||
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
|
||||
{
|
||||
descriptor = null!;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
return TryResolveExecutableByPath(name, out descriptor);
|
||||
}
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = NormalizePath(searchPath, name);
|
||||
if (TryResolveExecutableByPath(candidate, out descriptor))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
|
||||
{
|
||||
descriptor = null!;
|
||||
content = string.Empty;
|
||||
|
||||
if (!TryResolveFile(path, out var entry, out var resolvedPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entry.TryReadText(out content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = entry.ToDescriptor(resolvedPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalizedDirectory = NormalizeDirectory(path);
|
||||
var results = ImmutableArray.CreateBuilder<RootFileDescriptor>();
|
||||
|
||||
foreach (var entry in _entries.Values)
|
||||
{
|
||||
if (!string.Equals(entry.Parent, normalizedDirectory, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Kind == FileEntryKind.Symlink)
|
||||
{
|
||||
if (TryResolveFile(entry.Path, out var resolved, out var resolvedPath))
|
||||
{
|
||||
results.Add(resolved.ToDescriptor(resolvedPath));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(entry.ToDescriptor(entry.Path));
|
||||
}
|
||||
|
||||
return results.ToImmutable().Sort(static (left, right) => string.CompareOrdinal(left.Path, right.Path));
|
||||
}
|
||||
|
||||
public bool DirectoryExists(string path)
|
||||
{
|
||||
var normalized = NormalizeDirectory(path);
|
||||
if (_entries.TryGetValue(normalized, out var entry))
|
||||
{
|
||||
return entry.Kind == FileEntryKind.Directory;
|
||||
}
|
||||
|
||||
return _entries.Keys.Any(k => k.StartsWith(normalized + "/", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private bool TryResolveExecutableByPath(string path, out RootFileDescriptor descriptor)
|
||||
{
|
||||
descriptor = null!;
|
||||
|
||||
if (!TryResolveFile(path, out var entry, out var resolvedPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entry.IsExecutable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = entry.ToDescriptor(resolvedPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveFile(string path, out FileEntry entry, out string resolvedPath)
|
||||
{
|
||||
var normalized = NormalizePath(path);
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var depth = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (++depth > MaxSymlinkDepth)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!visited.Add(normalized))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_entries.TryGetValue(normalized, out var current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch (current.Kind)
|
||||
{
|
||||
case FileEntryKind.File:
|
||||
entry = current;
|
||||
resolvedPath = normalized;
|
||||
return true;
|
||||
case FileEntryKind.Symlink:
|
||||
normalized = ResolveSymlink(normalized, current.SymlinkTarget);
|
||||
continue;
|
||||
case FileEntryKind.Directory:
|
||||
// cannot resolve to directory
|
||||
entry = null!;
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
default:
|
||||
entry = null!;
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
entry = null!;
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveSymlink(string sourcePath, string? target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
if (target.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return NormalizePath(target);
|
||||
}
|
||||
|
||||
var directory = NormalizeDirectory(IOPath.GetDirectoryName(sourcePath));
|
||||
return NormalizePath(directory, target);
|
||||
}
|
||||
|
||||
private static string NormalizeDirectory(string? path)
|
||||
{
|
||||
var normalized = NormalizePath(path);
|
||||
if (normalized.Length > 1 && normalized.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized[..^1];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string? path)
|
||||
=> NormalizePath("/", path);
|
||||
|
||||
private static string NormalizePath(string basePath, string? relative)
|
||||
{
|
||||
var combined = string.IsNullOrWhiteSpace(relative)
|
||||
? basePath
|
||||
: relative.StartsWith("/", StringComparison.Ordinal)
|
||||
? relative
|
||||
: $"{basePath}/{relative}";
|
||||
|
||||
var text = combined.Replace('\\', '/');
|
||||
if (!text.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
text = "/" + text;
|
||||
}
|
||||
|
||||
var parts = new Stack<string>();
|
||||
foreach (var segment in text.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (segment == ".")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment == "..")
|
||||
{
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
parts.Pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Push(segment);
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
foreach (var segment in parts.Reverse())
|
||||
{
|
||||
builder.Append('/').Append(segment);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
private readonly Dictionary<string, FileEntry> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
public Builder()
|
||||
{
|
||||
_entries["/"] = FileEntry.Directory("/", null);
|
||||
}
|
||||
|
||||
public void ApplyDirectoryLayer(LayerDirectory layer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layer);
|
||||
var root = layer.Path;
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Layer directory '{root}' was not found.");
|
||||
}
|
||||
|
||||
ApplyDirectoryEntry("/", layer.Digest);
|
||||
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(root);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var current = stack.Pop();
|
||||
foreach (var entryPath in Directory.EnumerateFileSystemEntries(current, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var relative = IOPath.GetRelativePath(root, entryPath);
|
||||
var normalized = NormalizePath(relative);
|
||||
var fileName = IOPath.GetFileName(normalized);
|
||||
|
||||
if (IsWhiteoutEntry(fileName))
|
||||
{
|
||||
HandleWhiteout(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributes = File.GetAttributes(entryPath);
|
||||
var isSymlink = attributes.HasFlag(FileAttributes.ReparsePoint);
|
||||
|
||||
if (Directory.Exists(entryPath) && !isSymlink)
|
||||
{
|
||||
ApplyDirectoryEntry(normalized, layer.Digest);
|
||||
stack.Push(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSymlink)
|
||||
{
|
||||
var linkTarget = GetLinkTarget(entryPath);
|
||||
_entries[normalized] = FileEntry.Symlink(normalized, layer.Digest, linkTarget, parent: GetParent(normalized));
|
||||
continue;
|
||||
}
|
||||
|
||||
var isExecutable = InferExecutable(entryPath, attributes);
|
||||
var contentProvider = FileContentProvider.FromFile(entryPath);
|
||||
var shebang = ExtractShebang(contentProvider.Peek(MaxCachedTextBytes));
|
||||
|
||||
EnsureAncestry(normalized, layer.Digest);
|
||||
_entries[normalized] = FileEntry.File(
|
||||
normalized,
|
||||
layer.Digest,
|
||||
isExecutable,
|
||||
shebang,
|
||||
contentProvider,
|
||||
parent: GetParent(normalized));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyArchiveLayer(LayerArchive layer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layer);
|
||||
if (!File.Exists(layer.Path))
|
||||
{
|
||||
throw new FileNotFoundException("Layer archive not found.", layer.Path);
|
||||
}
|
||||
|
||||
using var archiveStream = File.OpenRead(layer.Path);
|
||||
using var reader = new TarReader(OpenPossiblyCompressedStream(archiveStream, layer.Path), leaveOpen: false);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = reader.GetNextEntry()) is not null)
|
||||
{
|
||||
var normalized = NormalizePath(entry.Name);
|
||||
var fileName = IOPath.GetFileName(normalized);
|
||||
|
||||
if (IsWhiteoutEntry(fileName))
|
||||
{
|
||||
HandleWhiteout(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (entry.EntryType)
|
||||
{
|
||||
case TarEntryType.Directory:
|
||||
ApplyDirectoryEntry(normalized, layer.Digest);
|
||||
break;
|
||||
case TarEntryType.RegularFile:
|
||||
case TarEntryType.V7RegularFile:
|
||||
case TarEntryType.ContiguousFile:
|
||||
{
|
||||
var contentProvider = FileContentProvider.FromTarEntry(entry);
|
||||
var preview = contentProvider.Peek(MaxCachedTextBytes);
|
||||
var shebang = ExtractShebang(preview);
|
||||
var isExecutable = InferExecutable(entry);
|
||||
|
||||
EnsureAncestry(normalized, layer.Digest);
|
||||
_entries[normalized] = FileEntry.File(
|
||||
normalized,
|
||||
layer.Digest,
|
||||
isExecutable,
|
||||
shebang,
|
||||
contentProvider,
|
||||
parent: GetParent(normalized));
|
||||
break;
|
||||
}
|
||||
case TarEntryType.SymbolicLink:
|
||||
case TarEntryType.HardLink:
|
||||
{
|
||||
EnsureAncestry(normalized, layer.Digest);
|
||||
var target = string.IsNullOrWhiteSpace(entry.LinkName)
|
||||
? null
|
||||
: entry.LinkName;
|
||||
_entries[normalized] = FileEntry.Symlink(
|
||||
normalized,
|
||||
layer.Digest,
|
||||
target,
|
||||
parent: GetParent(normalized));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Ignore other entry types for now.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IDictionary<string, FileEntry> Build()
|
||||
{
|
||||
return _entries;
|
||||
}
|
||||
|
||||
private void ApplyDirectoryEntry(string path, string? digest)
|
||||
{
|
||||
var normalized = NormalizeDirectory(path);
|
||||
EnsureAncestry(normalized, digest);
|
||||
_entries[normalized] = FileEntry.Directory(normalized, digest);
|
||||
}
|
||||
|
||||
private void EnsureAncestry(string path, string? digest)
|
||||
{
|
||||
var current = GetParent(path);
|
||||
while (!string.Equals(current, "/", StringComparison.Ordinal))
|
||||
{
|
||||
if (_entries.TryGetValue(current, out var existing) && existing.Kind == FileEntryKind.Directory)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_entries[current] = FileEntry.Directory(current, digest);
|
||||
current = GetParent(current);
|
||||
}
|
||||
|
||||
if (!_entries.ContainsKey("/"))
|
||||
{
|
||||
_entries["/"] = FileEntry.Directory("/", digest);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleWhiteout(string path)
|
||||
{
|
||||
var fileName = IOPath.GetFileName(path);
|
||||
if (string.Equals(fileName, ".wh..wh..opq", StringComparison.Ordinal))
|
||||
{
|
||||
var directory = NormalizeDirectory(IOPath.GetDirectoryName(path));
|
||||
var keys = _entries.Keys
|
||||
.Where(k => k.StartsWith(directory + "/", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
_entries.Remove(key);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileName.StartsWith(".wh.", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetName = fileName[4..];
|
||||
var directoryPath = NormalizeDirectory(IOPath.GetDirectoryName(path));
|
||||
var targetPath = directoryPath == "/"
|
||||
? "/" + targetName
|
||||
: directoryPath + "/" + targetName;
|
||||
|
||||
var toRemove = _entries.Keys
|
||||
.Where(k => string.Equals(k, targetPath, StringComparison.Ordinal) ||
|
||||
k.StartsWith(targetPath + "/", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_entries.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWhiteoutEntry(string? fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return fileName == ".wh..wh..opq" || fileName.StartsWith(".wh.", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static Stream OpenPossiblyCompressedStream(Stream source, string path)
|
||||
{
|
||||
if (path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new GZipStream(source, CompressionMode.Decompress, leaveOpen: false);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private static string? GetLinkTarget(string entryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(entryPath);
|
||||
if (!string.IsNullOrEmpty(fileInfo.LinkTarget))
|
||||
{
|
||||
return fileInfo.LinkTarget;
|
||||
}
|
||||
|
||||
var directoryInfo = new DirectoryInfo(entryPath);
|
||||
return directoryInfo.LinkTarget;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetParent(string path)
|
||||
{
|
||||
var directory = IOPath.GetDirectoryName(path);
|
||||
return NormalizeDirectory(directory);
|
||||
}
|
||||
|
||||
private static bool InferExecutable(string path, FileAttributes attributes)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var extension = IOPath.GetExtension(path);
|
||||
return extension is ".exe" or ".bat" or ".cmd" or ".ps1" or ".com" or ".sh"
|
||||
or ".py" or ".pl" or ".rb" or ".js";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
return mode.HasFlag(UnixFileMode.UserExecute) ||
|
||||
mode.HasFlag(UnixFileMode.GroupExecute) ||
|
||||
mode.HasFlag(UnixFileMode.OtherExecute);
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool InferExecutable(TarEntry entry)
|
||||
{
|
||||
var mode = (UnixFileMode)entry.Mode;
|
||||
return mode.HasFlag(UnixFileMode.UserExecute) ||
|
||||
mode.HasFlag(UnixFileMode.GroupExecute) ||
|
||||
mode.HasFlag(UnixFileMode.OtherExecute);
|
||||
}
|
||||
|
||||
private static string? ExtractShebang(string? contentPreview)
|
||||
{
|
||||
if (string.IsNullOrEmpty(contentPreview))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var reader = new StringReader(contentPreview);
|
||||
var firstLine = reader.ReadLine();
|
||||
|
||||
if (firstLine is null || !firstLine.StartsWith("#!", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstLine[2..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FileEntry
|
||||
{
|
||||
private readonly FileContentProvider? _content;
|
||||
|
||||
private FileEntry(
|
||||
string path,
|
||||
string? layerDigest,
|
||||
FileEntryKind kind,
|
||||
bool isExecutable,
|
||||
string? shebang,
|
||||
FileContentProvider? content,
|
||||
string parent,
|
||||
string? symlinkTarget)
|
||||
{
|
||||
Path = path;
|
||||
LayerDigest = layerDigest;
|
||||
Kind = kind;
|
||||
IsExecutable = isExecutable;
|
||||
Shebang = shebang;
|
||||
_content = content;
|
||||
Parent = parent;
|
||||
SymlinkTarget = symlinkTarget;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string? LayerDigest { get; }
|
||||
|
||||
public FileEntryKind Kind { get; }
|
||||
|
||||
public bool IsExecutable { get; }
|
||||
|
||||
public string? Shebang { get; }
|
||||
|
||||
public string Parent { get; }
|
||||
|
||||
public string? SymlinkTarget { get; }
|
||||
|
||||
public RootFileDescriptor ToDescriptor(string resolvedPath)
|
||||
=> new(
|
||||
resolvedPath,
|
||||
LayerDigest,
|
||||
IsExecutable,
|
||||
Kind == FileEntryKind.Directory,
|
||||
Shebang);
|
||||
|
||||
public bool TryReadText(out string content)
|
||||
{
|
||||
if (Kind != FileEntryKind.File || _content is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _content.TryRead(out content);
|
||||
}
|
||||
|
||||
public static FileEntry File(
|
||||
string path,
|
||||
string? digest,
|
||||
bool isExecutable,
|
||||
string? shebang,
|
||||
FileContentProvider content,
|
||||
string parent)
|
||||
=> new(path, digest, FileEntryKind.File, isExecutable, shebang, content, parent, symlinkTarget: null);
|
||||
|
||||
public static FileEntry Directory(string path, string? digest)
|
||||
=> new(path, digest, FileEntryKind.Directory, isExecutable: false, shebang: null, content: null, parent: GetParent(path), symlinkTarget: null);
|
||||
|
||||
public static FileEntry Symlink(string path, string? digest, string? target, string parent)
|
||||
=> new(path, digest, FileEntryKind.Symlink, isExecutable: false, shebang: null, content: null, parent, target);
|
||||
|
||||
private static string GetParent(string path)
|
||||
=> NormalizeDirectory(IOPath.GetDirectoryName(path));
|
||||
}
|
||||
|
||||
private enum FileEntryKind
|
||||
{
|
||||
File,
|
||||
Directory,
|
||||
Symlink
|
||||
}
|
||||
|
||||
private sealed class FileContentProvider
|
||||
{
|
||||
private readonly Func<string?> _factory;
|
||||
private readonly Lazy<string?> _cached;
|
||||
|
||||
private FileContentProvider(Func<string?> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_cached = new Lazy<string?>(() => _factory(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public static FileContentProvider FromFile(string path)
|
||||
=> new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
public static FileContentProvider FromTarEntry(TarEntry entry)
|
||||
{
|
||||
return new FileContentProvider(() =>
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
entry.DataStream?.CopyTo(stream);
|
||||
if (stream.Length > MaxCachedTextBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
return reader.ReadToEnd();
|
||||
});
|
||||
}
|
||||
|
||||
public string? Peek(int maxBytes)
|
||||
{
|
||||
var content = _cached.Value;
|
||||
if (content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.Length * sizeof(char) <= maxBytes)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
return content[..Math.Min(content.Length, maxBytes / sizeof(char))];
|
||||
}
|
||||
|
||||
public bool TryRead(out string content)
|
||||
{
|
||||
var value = _cached.Value;
|
||||
if (value is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
content = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public interface IEntryTraceAnalyzer
|
||||
{
|
||||
ValueTask<EntryTraceGraph> ResolveAsync(
|
||||
EntrypointSpecification entrypoint,
|
||||
EntryTraceContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the deserialized OCI image config document.
|
||||
/// </summary>
|
||||
internal sealed class OciImageConfiguration
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public OciImageConfig? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("container_config")]
|
||||
public OciImageConfig? ContainerConfig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical representation of the OCI image config fields used by EntryTrace.
|
||||
/// </summary>
|
||||
public sealed class OciImageConfig
|
||||
{
|
||||
[JsonPropertyName("Env")]
|
||||
[JsonConverter(typeof(FlexibleStringListConverter))]
|
||||
public ImmutableArray<string> Environment { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("Entrypoint")]
|
||||
[JsonConverter(typeof(FlexibleStringListConverter))]
|
||||
public ImmutableArray<string> Entrypoint { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("Cmd")]
|
||||
[JsonConverter(typeof(FlexibleStringListConverter))]
|
||||
public ImmutableArray<string> Command { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("WorkingDir")]
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
[JsonPropertyName("User")]
|
||||
public string? User { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads <see cref="OciImageConfig"/> instances from OCI config JSON.
|
||||
/// </summary>
|
||||
public static class OciImageConfigLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static OciImageConfig Load(string filePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Load(stream);
|
||||
}
|
||||
|
||||
public static OciImageConfig Load(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
var configuration = JsonSerializer.Deserialize<OciImageConfiguration>(stream, SerializerOptions)
|
||||
?? throw new InvalidDataException("OCI image config is empty or invalid.");
|
||||
|
||||
if (configuration.Config is not null)
|
||||
{
|
||||
return configuration.Config;
|
||||
}
|
||||
|
||||
if (configuration.ContainerConfig is not null)
|
||||
{
|
||||
return configuration.ContainerConfig;
|
||||
}
|
||||
|
||||
throw new InvalidDataException("OCI image config does not include a config section.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FlexibleStringListConverter : JsonConverter<ImmutableArray<string>>
|
||||
{
|
||||
public override ImmutableArray<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.StartArray)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndArray)
|
||||
{
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
builder.Add(reader.GetString() ?? string.Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new JsonException($"Expected string elements in array but found {reader.TokenType}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
return ImmutableArray.Create(reader.GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
throw new JsonException($"Unsupported JSON token {reader.TokenType} for string array.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, ImmutableArray<string> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in value)
|
||||
{
|
||||
writer.WriteStringValue(entry);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
public abstract record ShellNode(ShellSpan Span);
|
||||
|
||||
public sealed record ShellScript(ImmutableArray<ShellNode> Nodes);
|
||||
|
||||
public sealed record ShellSpan(int StartLine, int StartColumn, int EndLine, int EndColumn);
|
||||
|
||||
public sealed record ShellCommandNode(
|
||||
string Command,
|
||||
ImmutableArray<ShellToken> Arguments,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
|
||||
public sealed record ShellIncludeNode(
|
||||
string PathExpression,
|
||||
ImmutableArray<ShellToken> Arguments,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
|
||||
public sealed record ShellExecNode(
|
||||
ImmutableArray<ShellToken> Arguments,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
|
||||
public sealed record ShellIfNode(
|
||||
ImmutableArray<ShellConditionalBranch> Branches,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
|
||||
public sealed record ShellConditionalBranch(
|
||||
ShellConditionalKind Kind,
|
||||
ImmutableArray<ShellNode> Body,
|
||||
ShellSpan Span,
|
||||
string? PredicateSummary);
|
||||
|
||||
public enum ShellConditionalKind
|
||||
{
|
||||
If,
|
||||
Elif,
|
||||
Else
|
||||
}
|
||||
|
||||
public sealed record ShellCaseNode(
|
||||
ImmutableArray<ShellCaseArm> Arms,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
|
||||
public sealed record ShellCaseArm(
|
||||
ImmutableArray<string> Patterns,
|
||||
ImmutableArray<ShellNode> Body,
|
||||
ShellSpan Span);
|
||||
|
||||
public sealed record ShellRunPartsNode(
|
||||
string DirectoryExpression,
|
||||
ImmutableArray<ShellToken> Arguments,
|
||||
ShellSpan Span) : ShellNode(Span);
|
||||
@@ -0,0 +1,485 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic parser producing a lightweight AST for Bourne shell constructs needed by EntryTrace.
|
||||
/// Supports: simple commands, exec, source/dot, run-parts, if/elif/else/fi, case/esac.
|
||||
/// </summary>
|
||||
public sealed class ShellParser
|
||||
{
|
||||
private readonly IReadOnlyList<ShellToken> _tokens;
|
||||
private int _index;
|
||||
|
||||
private ShellParser(IReadOnlyList<ShellToken> tokens)
|
||||
{
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
public static ShellScript Parse(string source)
|
||||
{
|
||||
var tokenizer = new ShellTokenizer();
|
||||
var tokens = tokenizer.Tokenize(source);
|
||||
var parser = new ShellParser(tokens);
|
||||
var nodes = parser.ParseNodes(untilKeywords: null);
|
||||
return new ShellScript(nodes.ToImmutableArray());
|
||||
}
|
||||
|
||||
private List<ShellNode> ParseNodes(HashSet<string>? untilKeywords)
|
||||
{
|
||||
var nodes = new List<ShellNode>();
|
||||
|
||||
while (true)
|
||||
{
|
||||
SkipNewLines();
|
||||
var token = Peek();
|
||||
|
||||
if (token.Kind == ShellTokenKind.EndOfFile)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.Kind == ShellTokenKind.Word && untilKeywords is not null && untilKeywords.Contains(token.Value))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ShellNode? node = token.Kind switch
|
||||
{
|
||||
ShellTokenKind.Word when token.Value == "if" => ParseIf(),
|
||||
ShellTokenKind.Word when token.Value == "case" => ParseCase(),
|
||||
_ => ParseCommandLike()
|
||||
};
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
SkipCommandSeparators();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private ShellNode ParseCommandLike()
|
||||
{
|
||||
var start = Peek();
|
||||
var tokens = ReadUntilTerminator();
|
||||
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return new ShellCommandNode(string.Empty, ImmutableArray<ShellToken>.Empty, CreateSpan(start, start));
|
||||
}
|
||||
|
||||
var normalizedName = ExtractCommandName(tokens);
|
||||
var immutableTokens = tokens.ToImmutableArray();
|
||||
var span = CreateSpan(tokens[0], tokens[^1]);
|
||||
|
||||
return normalizedName switch
|
||||
{
|
||||
"exec" => new ShellExecNode(immutableTokens, span),
|
||||
"source" or "." => new ShellIncludeNode(
|
||||
ExtractPrimaryArgument(immutableTokens),
|
||||
immutableTokens,
|
||||
span),
|
||||
"run-parts" => new ShellRunPartsNode(
|
||||
ExtractPrimaryArgument(immutableTokens),
|
||||
immutableTokens,
|
||||
span),
|
||||
_ => new ShellCommandNode(normalizedName, immutableTokens, span)
|
||||
};
|
||||
}
|
||||
|
||||
private ShellIfNode ParseIf()
|
||||
{
|
||||
var start = Expect(ShellTokenKind.Word, "if");
|
||||
var predicateTokens = ReadUntilKeyword("then");
|
||||
Expect(ShellTokenKind.Word, "then");
|
||||
|
||||
var branches = new List<ShellConditionalBranch>();
|
||||
var predicateSummary = JoinTokens(predicateTokens);
|
||||
var thenNodes = ParseNodes(new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"elif",
|
||||
"else",
|
||||
"fi"
|
||||
});
|
||||
|
||||
branches.Add(new ShellConditionalBranch(
|
||||
ShellConditionalKind.If,
|
||||
thenNodes.ToImmutableArray(),
|
||||
CreateSpan(start, thenNodes.LastOrDefault()?.Span ?? CreateSpan(start, start)),
|
||||
predicateSummary));
|
||||
|
||||
while (true)
|
||||
{
|
||||
SkipNewLines();
|
||||
var next = Peek();
|
||||
if (next.Kind != ShellTokenKind.Word)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (next.Value == "elif")
|
||||
{
|
||||
var elifStart = Advance();
|
||||
var elifPredicate = ReadUntilKeyword("then");
|
||||
Expect(ShellTokenKind.Word, "then");
|
||||
var elifBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"elif",
|
||||
"else",
|
||||
"fi"
|
||||
});
|
||||
var span = elifBody.Count > 0
|
||||
? CreateSpan(elifStart, elifBody[^1].Span)
|
||||
: CreateSpan(elifStart, elifStart);
|
||||
|
||||
branches.Add(new ShellConditionalBranch(
|
||||
ShellConditionalKind.Elif,
|
||||
elifBody.ToImmutableArray(),
|
||||
span,
|
||||
JoinTokens(elifPredicate)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next.Value == "else")
|
||||
{
|
||||
var elseStart = Advance();
|
||||
var elseBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"fi"
|
||||
});
|
||||
branches.Add(new ShellConditionalBranch(
|
||||
ShellConditionalKind.Else,
|
||||
elseBody.ToImmutableArray(),
|
||||
elseBody.Count > 0 ? CreateSpan(elseStart, elseBody[^1].Span) : CreateSpan(elseStart, elseStart),
|
||||
null));
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Expect(ShellTokenKind.Word, "fi");
|
||||
var end = Previous();
|
||||
return new ShellIfNode(branches.ToImmutableArray(), CreateSpan(start, end));
|
||||
}
|
||||
|
||||
private ShellCaseNode ParseCase()
|
||||
{
|
||||
var start = Expect(ShellTokenKind.Word, "case");
|
||||
var selectorTokens = ReadUntilKeyword("in");
|
||||
Expect(ShellTokenKind.Word, "in");
|
||||
|
||||
var arms = new List<ShellCaseArm>();
|
||||
while (true)
|
||||
{
|
||||
SkipNewLines();
|
||||
var token = Peek();
|
||||
if (token.Kind == ShellTokenKind.Word && token.Value == "esac")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.Kind == ShellTokenKind.EndOfFile)
|
||||
{
|
||||
throw new FormatException("Unexpected end of file while parsing case arms.");
|
||||
}
|
||||
|
||||
var patterns = ReadPatterns();
|
||||
Expect(ShellTokenKind.Operator, ")");
|
||||
|
||||
var body = ParseNodes(new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
";;",
|
||||
"esac"
|
||||
});
|
||||
|
||||
ShellSpan span;
|
||||
if (body.Count > 0)
|
||||
{
|
||||
span = CreateSpan(patterns.FirstToken ?? token, body[^1].Span);
|
||||
}
|
||||
else
|
||||
{
|
||||
span = CreateSpan(patterns.FirstToken ?? token, token);
|
||||
}
|
||||
|
||||
arms.Add(new ShellCaseArm(
|
||||
patterns.Values.ToImmutableArray(),
|
||||
body.ToImmutableArray(),
|
||||
span));
|
||||
|
||||
SkipNewLines();
|
||||
var separator = Peek();
|
||||
if (separator.Kind == ShellTokenKind.Operator && separator.Value == ";;")
|
||||
{
|
||||
Advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (separator.Kind == ShellTokenKind.Word && separator.Value == "esac")
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Expect(ShellTokenKind.Word, "esac");
|
||||
return new ShellCaseNode(arms.ToImmutableArray(), CreateSpan(start, Previous()));
|
||||
|
||||
(List<string> Values, ShellToken? FirstToken) ReadPatterns()
|
||||
{
|
||||
var values = new List<string>();
|
||||
ShellToken? first = null;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var current = Peek();
|
||||
if (current.Kind is ShellTokenKind.Operator && current.Value is ")" or "|")
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
values.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
|
||||
if (current.Value == "|")
|
||||
{
|
||||
Advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.Kind == ShellTokenKind.EndOfFile)
|
||||
{
|
||||
throw new FormatException("Unexpected EOF in case arm pattern.");
|
||||
}
|
||||
|
||||
if (first is null)
|
||||
{
|
||||
first = current;
|
||||
}
|
||||
|
||||
sb.Append(current.Value);
|
||||
Advance();
|
||||
}
|
||||
|
||||
if (values.Count == 0 && sb.Length > 0)
|
||||
{
|
||||
values.Add(sb.ToString());
|
||||
}
|
||||
|
||||
return (values, first);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ShellToken> ReadUntilTerminator()
|
||||
{
|
||||
var tokens = new List<ShellToken>();
|
||||
while (true)
|
||||
{
|
||||
var token = Peek();
|
||||
if (token.Kind is ShellTokenKind.EndOfFile or ShellTokenKind.NewLine)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.Kind == ShellTokenKind.Operator && token.Value is ";" or "&&" or "||")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokens.Add(Advance());
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private ImmutableArray<ShellToken> ReadUntilKeyword(string keyword)
|
||||
{
|
||||
var tokens = new List<ShellToken>();
|
||||
while (true)
|
||||
{
|
||||
var token = Peek();
|
||||
if (token.Kind == ShellTokenKind.EndOfFile)
|
||||
{
|
||||
throw new FormatException($"Unexpected EOF while looking for keyword '{keyword}'.");
|
||||
}
|
||||
|
||||
if (token.Kind == ShellTokenKind.Word && token.Value == keyword)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokens.Add(Advance());
|
||||
}
|
||||
|
||||
return tokens.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string ExtractCommandName(IReadOnlyList<ShellToken> tokens)
|
||||
{
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (token.Kind is not ShellTokenKind.Word and not ShellTokenKind.SingleQuoted and not ShellTokenKind.DoubleQuoted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.Value.Contains('=', StringComparison.Ordinal))
|
||||
{
|
||||
// Skip environment assignments e.g. FOO=bar exec /app
|
||||
var eqIndex = token.Value.IndexOf('=', StringComparison.Ordinal);
|
||||
if (eqIndex > 0 && token.Value[..eqIndex].All(IsIdentifierChar))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizeCommandName(token.Value);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
|
||||
static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_';
|
||||
}
|
||||
|
||||
private static string NormalizeCommandName(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
"." => ".",
|
||||
_ => value.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private void SkipCommandSeparators()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var token = Peek();
|
||||
if (token.Kind == ShellTokenKind.NewLine)
|
||||
{
|
||||
Advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.Kind == ShellTokenKind.Operator && (token.Value == ";" || token.Value == "&"))
|
||||
{
|
||||
Advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SkipNewLines()
|
||||
{
|
||||
while (Peek().Kind == ShellTokenKind.NewLine)
|
||||
{
|
||||
Advance();
|
||||
}
|
||||
}
|
||||
|
||||
private ShellToken Expect(ShellTokenKind kind, string? value = null)
|
||||
{
|
||||
var token = Peek();
|
||||
if (token.Kind != kind || (value is not null && token.Value != value))
|
||||
{
|
||||
throw new FormatException($"Unexpected token '{token.Value}' at line {token.Line}, expected {value ?? kind.ToString()}.");
|
||||
}
|
||||
|
||||
return Advance();
|
||||
}
|
||||
|
||||
private ShellToken Advance()
|
||||
{
|
||||
if (_index >= _tokens.Count)
|
||||
{
|
||||
return _tokens[^1];
|
||||
}
|
||||
|
||||
return _tokens[_index++];
|
||||
}
|
||||
|
||||
private ShellToken Peek()
|
||||
{
|
||||
if (_index >= _tokens.Count)
|
||||
{
|
||||
return _tokens[^1];
|
||||
}
|
||||
|
||||
return _tokens[_index];
|
||||
}
|
||||
|
||||
private ShellToken Previous()
|
||||
{
|
||||
if (_index == 0)
|
||||
{
|
||||
return _tokens[0];
|
||||
}
|
||||
|
||||
return _tokens[_index - 1];
|
||||
}
|
||||
|
||||
private static ShellSpan CreateSpan(ShellToken start, ShellToken end)
|
||||
{
|
||||
return new ShellSpan(start.Line, start.Column, end.Line, end.Column + end.Value.Length);
|
||||
}
|
||||
|
||||
private static ShellSpan CreateSpan(ShellToken start, ShellSpan end)
|
||||
{
|
||||
return new ShellSpan(start.Line, start.Column, end.EndLine, end.EndColumn);
|
||||
}
|
||||
|
||||
private static string JoinTokens(IEnumerable<ShellToken> tokens)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var first = true;
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(token.Value);
|
||||
first = false;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractPrimaryArgument(ImmutableArray<ShellToken> tokens)
|
||||
{
|
||||
if (tokens.Length <= 1)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
var token = tokens[i];
|
||||
if (token.Kind is ShellTokenKind.Word or ShellTokenKind.SingleQuoted or ShellTokenKind.DoubleQuoted)
|
||||
{
|
||||
return token.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Token produced by the shell lexer.
|
||||
/// </summary>
|
||||
public readonly record struct ShellToken(ShellTokenKind Kind, string Value, int Line, int Column);
|
||||
|
||||
public enum ShellTokenKind
|
||||
{
|
||||
Word,
|
||||
SingleQuoted,
|
||||
DoubleQuoted,
|
||||
Operator,
|
||||
NewLine,
|
||||
EndOfFile
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight Bourne shell tokenizer sufficient for ENTRYPOINT scripts.
|
||||
/// Deterministic: emits tokens in source order without normalization.
|
||||
/// </summary>
|
||||
public sealed class ShellTokenizer
|
||||
{
|
||||
public IReadOnlyList<ShellToken> Tokenize(string source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
var tokens = new List<ShellToken>();
|
||||
var line = 1;
|
||||
var column = 1;
|
||||
var index = 0;
|
||||
|
||||
while (index < source.Length)
|
||||
{
|
||||
var ch = source[index];
|
||||
|
||||
if (ch == '\r')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\n')
|
||||
{
|
||||
tokens.Add(new ShellToken(ShellTokenKind.NewLine, "\n", line, column));
|
||||
index++;
|
||||
line++;
|
||||
column = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
index++;
|
||||
column++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '#')
|
||||
{
|
||||
// Comment: skip until newline.
|
||||
while (index < source.Length && source[index] != '\n')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsOperatorStart(ch))
|
||||
{
|
||||
var opStartColumn = column;
|
||||
var op = ConsumeOperator(source, ref index, ref column);
|
||||
tokens.Add(new ShellToken(ShellTokenKind.Operator, op, line, opStartColumn));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\'')
|
||||
{
|
||||
var (value, length) = ConsumeSingleQuoted(source, index + 1);
|
||||
tokens.Add(new ShellToken(ShellTokenKind.SingleQuoted, value, line, column));
|
||||
index += length + 2;
|
||||
column += length + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '"')
|
||||
{
|
||||
var (value, length) = ConsumeDoubleQuoted(source, index + 1);
|
||||
tokens.Add(new ShellToken(ShellTokenKind.DoubleQuoted, value, line, column));
|
||||
index += length + 2;
|
||||
column += length + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (word, consumed) = ConsumeWord(source, index);
|
||||
tokens.Add(new ShellToken(ShellTokenKind.Word, word, line, column));
|
||||
index += consumed;
|
||||
column += consumed;
|
||||
}
|
||||
|
||||
tokens.Add(new ShellToken(ShellTokenKind.EndOfFile, string.Empty, line, column));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static bool IsOperatorStart(char ch) => ch switch
|
||||
{
|
||||
';' or '&' or '|' or '(' or ')' => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static string ConsumeOperator(string source, ref int index, ref int column)
|
||||
{
|
||||
var start = index;
|
||||
var ch = source[index];
|
||||
index++;
|
||||
column++;
|
||||
|
||||
if (index < source.Length)
|
||||
{
|
||||
var next = source[index];
|
||||
if ((ch == '&' && next == '&') ||
|
||||
(ch == '|' && next == '|') ||
|
||||
(ch == ';' && next == ';'))
|
||||
{
|
||||
index++;
|
||||
column++;
|
||||
}
|
||||
}
|
||||
|
||||
return source[start..index];
|
||||
}
|
||||
|
||||
private static (string Value, int Length) ConsumeSingleQuoted(string source, int startIndex)
|
||||
{
|
||||
var end = startIndex;
|
||||
while (end < source.Length && source[end] != '\'')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end >= source.Length)
|
||||
{
|
||||
throw new FormatException("Unterminated single-quoted string in entrypoint script.");
|
||||
}
|
||||
|
||||
return (source[startIndex..end], end - startIndex);
|
||||
}
|
||||
|
||||
private static (string Value, int Length) ConsumeDoubleQuoted(string source, int startIndex)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var index = startIndex;
|
||||
|
||||
while (index < source.Length)
|
||||
{
|
||||
var ch = source[index];
|
||||
if (ch == '"')
|
||||
{
|
||||
return (builder.ToString(), index - startIndex);
|
||||
}
|
||||
|
||||
if (ch == '\\' && index + 1 < source.Length)
|
||||
{
|
||||
var next = source[index + 1];
|
||||
if (next is '"' or '\\' or '$' or '`' or '\n')
|
||||
{
|
||||
builder.Append(next);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append(ch);
|
||||
index++;
|
||||
}
|
||||
|
||||
throw new FormatException("Unterminated double-quoted string in entrypoint script.");
|
||||
}
|
||||
|
||||
private static (string Value, int Length) ConsumeWord(string source, int startIndex)
|
||||
{
|
||||
var index = startIndex;
|
||||
while (index < source.Length)
|
||||
{
|
||||
var ch = source[index];
|
||||
if (char.IsWhiteSpace(ch) || ch == '\n' || ch == '\r' || IsOperatorStart(ch) || ch == '#' )
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (ch == '\\' && index + 1 < source.Length && source[index + 1] == '\n')
|
||||
{
|
||||
// Line continuation.
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index == startIndex)
|
||||
{
|
||||
throw new InvalidOperationException("Tokenizer failed to advance while consuming word.");
|
||||
}
|
||||
|
||||
var text = source[startIndex..index];
|
||||
return (text, index - startIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<EntryTraceAnalyzerOptions>()
|
||||
.BindConfiguration(EntryTraceAnalyzerOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<EntryTraceMetrics>();
|
||||
services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
# EntryTrace Analyzer Task Board (Sprint 10)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-ENTRYTRACE-10-401 | DONE (2025-10-19) | EntryTrace Guild | Scanner Core contracts | Implement deterministic POSIX shell AST parser covering exec/command/source/run-parts/case/if used by ENTRYPOINT scripts. | Parser emits stable AST and serialization tests prove determinism for representative fixtures; see `ShellParserTests`. |
|
||||
| SCANNER-ENTRYTRACE-10-402 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401 | Resolve commands across layered rootfs, tracking evidence per hop (PATH hit, layer origin, shebang). | Resolver returns terminal program path with layer attribution for fixtures; deterministic traversal asserted in `EntryTraceAnalyzerTests.ResolveAsync_IsDeterministic`. |
|
||||
| SCANNER-ENTRYTRACE-10-403 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Follow interpreter wrappers (shell → Python/Node/Java launchers) to terminal target, including module/jar detection. | Interpreter tracer reports correct module/script for language launchers; tests cover Python/Node/Java wrappers. |
|
||||
| SCANNER-ENTRYTRACE-10-404 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Build Python entry analyzer detecting venv shebangs, module invocations, `-m` usage and record usage flag. | Python fixtures produce expected module metadata (`python-module` edge) and diagnostics for missing scripts. |
|
||||
| SCANNER-ENTRYTRACE-10-405 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Implement Node/Java launcher analyzer capturing script/jar targets including npm lifecycle wrappers. | Node/Java fixtures resolved with evidence chain; `RunParts` coverage ensures child scripts traced. |
|
||||
| SCANNER-ENTRYTRACE-10-406 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Surface explainability + diagnostics for unresolved constructs and emit metrics counters. | Diagnostics catalog enumerates unknown reasons; metrics wired via `EntryTraceMetrics`; explainability doc updated. |
|
||||
| SCANNER-ENTRYTRACE-10-407 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401..406 | Package EntryTrace analyzers as restart-time plug-ins with manifest + host registration. | Plug-in manifest under `plugins/scanner/entrytrace/`; restart-only policy documented; DI extension exposes `AddEntryTraceAnalyzer`. |
|
||||
| SCANNER-ENTRYTRACE-18-501 | DONE (2025-10-29) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-407 | Build OCI config reader and layered rootfs adapter so EntryTrace can hydrate PATH, WorkingDir, User, and provenance from real images. | Fixtures covering tar/dir inputs produce deterministic `IRootFileSystem` descriptors (whiteouts, symlinks, shebangs) and `EntrypointSpecification` derived from config merges with default PATH fallbacks. |
|
||||
| SCANNER-ENTRYTRACE-18-502 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-501 | Expand chain walker with init shim/user-switch/supervisor recognition plus env/workdir accumulation and guarded edges. | Graph nodes annotate tini/dumb-init/gosu/su-exec/s6/supervisord/runit branches with capability tags, environment deltas, and guard metadata validated against fixture scripts. |
|
||||
| SCANNER-ENTRYTRACE-18-503 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-502 | Introduce target classifier + EntryPlan handoff with confidence scoring for ELF/Java/.NET/Node/Python and user/workdir context. | Analyzer returns typed targets with confidence metrics and per-branch EntryPlans exercised via golden fixtures and language analyzer stubs. |
|
||||
| SCANNER-ENTRYTRACE-18-504 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
|
||||
|
||||
## Status Review — 2025-10-19
|
||||
|
||||
- Confirmed Wave 0 instructions for EntryTrace Guild; SCANNER-ENTRYTRACE-10-401..407 already marked complete.
|
||||
- No outstanding prerequisites identified during review; readiness noted for any follow-on work.
|
||||
Reference in New Issue
Block a user