up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,51 +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();
}
}
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();
}
}

View File

@@ -1,26 +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;
}
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;
}

View File

@@ -1,12 +1,12 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.EntryTrace.FileSystem;
namespace StellaOps.Scanner.EntryTrace;
/// <summary>
/// Provides runtime context for entry trace analysis.
/// </summary>
/// <summary>
/// Provides runtime context for entry trace analysis.
/// </summary>
public sealed record EntryTraceContext(
IRootFileSystem FileSystem,
ImmutableDictionary<string, string> Environment,

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
@@ -9,33 +9,33 @@ using StellaOps.Scanner.EntryTrace.FileSystem;
using StellaOps.Scanner.EntryTrace.Parsing;
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);
/// <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,
@@ -477,132 +477,132 @@ public static class EntryTraceImageContextFactory
private static string CreateSignature(ImmutableArray<string> command)
=> string.Join('\u001F', command);
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";
}
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";
}

View File

@@ -1,60 +1,60 @@
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>
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,
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,
/// </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,
@@ -94,26 +94,26 @@ public enum EntryTraceTerminalType
/// <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>
/// </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,
@@ -123,27 +123,27 @@ public sealed record EntryTraceNode(
EntryTraceEvidence? Evidence,
EntryTraceSpan? Span,
ImmutableDictionary<string, string>? Metadata);
/// <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>
/// <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(

View File

@@ -1,71 +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);
}
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);
}

View File

@@ -1,17 +1,17 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace.FileSystem;
/// <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>
/// 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>
@@ -26,19 +26,19 @@ public interface IRootFileSystem
/// 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);
/// <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);

View File

@@ -1,9 +1,9 @@
namespace StellaOps.Scanner.EntryTrace;
public interface IEntryTraceAnalyzer
{
ValueTask<EntryTraceGraph> ResolveAsync(
EntrypointSpecification entrypoint,
EntryTraceContext context,
CancellationToken cancellationToken = default);
}
namespace StellaOps.Scanner.EntryTrace;
public interface IEntryTraceAnalyzer
{
ValueTask<EntryTraceGraph> ResolveAsync(
EntrypointSpecification entrypoint,
EntryTraceContext context,
CancellationToken cancellationToken = default);
}

View File

@@ -1,13 +1,13 @@
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>
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")]
@@ -19,24 +19,24 @@ internal sealed class OciImageConfiguration
[JsonPropertyName("history")]
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
}
/// <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;
/// <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; }
@@ -46,31 +46,31 @@ public sealed class OciImageConfig
[JsonIgnore]
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
}
/// <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.");
/// <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.");
var baseConfig = configuration.Config ?? configuration.ContainerConfig
?? throw new InvalidDataException("OCI image config does not include a config section.");
@@ -85,44 +85,44 @@ public static class OciImageConfigLoader
};
}
}
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.");
}
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();

View File

@@ -1,54 +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);
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);

View File

@@ -1,485 +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;
}
}
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;
}
}

View File

@@ -1,16 +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
}
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
}

View File

@@ -1,200 +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);
}
}
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);
}
}