Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,165 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class EntryTraceAnalyzerTests
{
private static EntryTraceAnalyzer CreateAnalyzer()
{
var options = Options.Create(new EntryTraceAnalyzerOptions
{
MaxDepth = 32,
FollowRunParts = true
});
return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance);
}
[Fact]
public async Task ResolveAsync_FollowsShellIncludeAndPythonModule()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /opt/setup.sh
exec python -m app.main --flag
""");
fs.AddFile("/opt/setup.sh", """
#!/bin/sh
run-parts /opt/setup.d
""");
fs.AddDirectory("/opt/setup.d");
fs.AddFile("/opt/setup.d/001-node.sh", """
#!/bin/sh
exec node /app/server.js
""");
fs.AddFile("/opt/setup.d/010-java.sh", """
#!/bin/sh
java -jar /app/app.jar
""");
fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true);
fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true);
fs.AddFile("/usr/bin/java", "", executable: true);
fs.AddFile("/app/server.js", "console.log('hello');", executable: true);
fs.AddFile("/app/app.jar", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin", "/usr/local/bin"),
"/",
"root",
"sha256:image",
"scan-entrytrace-1",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Empty(result.Diagnostics);
var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray();
Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames);
Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames);
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d");
Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main");
}
[Fact]
public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /missing/setup.sh
exec /bin/true
""");
fs.AddFile("/bin/true", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/bin"),
"/",
"root",
"sha256:image",
"scan-entrytrace-2",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
Assert.Single(result.Diagnostics);
Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason);
}
[Fact]
public async Task ResolveAsync_IsDeterministic()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
exec node /app/index.js
""");
fs.AddFile("/usr/bin/node", string.Empty, executable: true);
fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin"),
"/",
"root",
"sha256:image",
"scan-entrytrace-3",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var first = await analyzer.ResolveAsync(spec, context);
var second = await analyzer.ResolveAsync(spec, context);
Assert.Equal(first.Outcome, second.Outcome);
Assert.Equal(first.Diagnostics, second.Diagnostics);
Assert.Equal(first.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray(), second.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray());
Assert.Equal(first.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray(),
second.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray());
}
[Fact]
public async Task ResolveAsync_HandlesCmdShellScript()
{
var fs = new TestRootFileSystem();
fs.AddFile("/windows/system32/cmd.exe", string.Empty, executable: true);
fs.AddFile("/scripts/start.bat", "@echo off\r\necho start\r\n", executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/windows/system32"),
"/",
"root",
"sha256:image",
"scan-entrytrace-windows",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "cmd.exe", "/c", "/scripts/start.bat" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
Assert.Contains(result.Nodes, node => node.Kind == EntryTraceNodeKind.Script && node.DisplayName == "/scripts/start.bat");
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.UnsupportedSyntax);
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Immutable;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class EntryTraceImageContextFactoryTests
{
[Fact]
public void Create_UsesEnvironmentAndEntrypointFromConfig()
{
var json = """
{
"config": {
"Env": ["PATH=/custom/bin:/usr/bin", "FOO=bar"],
"Entrypoint": ["/bin/sh", "-c"],
"Cmd": ["./start.sh"],
"WorkingDir": "/srv/app",
"User": "1000:1000"
}
}
""";
var config = OciImageConfigLoader.Load(new MemoryStream(Encoding.UTF8.GetBytes(json)));
var options = new EntryTraceAnalyzerOptions
{
DefaultPath = "/default/bin"
};
var fs = new TestRootFileSystem();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:testimage",
"scan-001",
NullLogger.Instance);
Assert.Equal("/bin/sh", imageContext.Entrypoint.Entrypoint[0]);
Assert.Equal("./start.sh", imageContext.Entrypoint.Command[0]);
Assert.Equal("/srv/app", imageContext.Context.WorkingDirectory);
Assert.Equal("1000:1000", imageContext.Context.User);
Assert.Equal("sha256:testimage", imageContext.Context.ImageDigest);
Assert.Equal("scan-001", imageContext.Context.ScanId);
Assert.True(imageContext.Context.Environment.ContainsKey("FOO"));
Assert.Equal("bar", imageContext.Context.Environment["FOO"]);
Assert.Equal("/custom/bin:/usr/bin", string.Join(":", imageContext.Context.Path));
}
[Fact]
public void Create_FallsBackToDefaultPathWhenMissing()
{
var json = """
{
"config": {
"Env": ["FOO=bar"],
"Cmd": ["node", "server.js"]
}
}
""";
var config = OciImageConfigLoader.Load(new MemoryStream(Encoding.UTF8.GetBytes(json)));
var options = new EntryTraceAnalyzerOptions
{
DefaultPath = "/usr/local/sbin:/usr/local/bin"
};
var fs = new TestRootFileSystem();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:abc",
"scan-xyz",
NullLogger.Instance);
Assert.Equal("/usr/local/sbin:/usr/local/bin", string.Join(":", imageContext.Context.Path));
Assert.Equal("root", imageContext.Context.User);
Assert.Equal("/", imageContext.Context.WorkingDirectory);
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Formats.Tar;
using System.IO;
using System.Text;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class LayeredRootFileSystemTests : IDisposable
{
private readonly string _tempRoot;
public LayeredRootFileSystemTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-layerfs-{Guid.NewGuid():n}");
Directory.CreateDirectory(_tempRoot);
}
[Fact]
public void FromDirectories_HandlesWhiteoutsAndResolution()
{
var layer1 = CreateLayerDirectory("layer1");
var layer2 = CreateLayerDirectory("layer2");
var usrBin1 = Path.Combine(layer1, "usr", "bin");
Directory.CreateDirectory(usrBin1);
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
var optDirectory1 = Path.Combine(layer1, "opt");
Directory.CreateDirectory(optDirectory1);
File.WriteAllText(Path.Combine(optDirectory1, "setup.sh"), "echo setup\n");
var optDirectory2 = Path.Combine(layer2, "opt");
Directory.CreateDirectory(optDirectory2);
File.WriteAllText(Path.Combine(optDirectory2, ".wh.setup.sh"), string.Empty);
var fs = LayeredRootFileSystem.FromDirectories(new[]
{
new LayeredRootFileSystem.LayerDirectory("sha256:layer1", layer1),
new LayeredRootFileSystem.LayerDirectory("sha256:layer2", layer2)
});
Assert.True(fs.TryResolveExecutable("entrypoint.sh", new[] { "/usr/bin" }, out var descriptor));
Assert.Equal("/usr/bin/entrypoint.sh", descriptor.Path);
Assert.Equal("sha256:layer1", descriptor.LayerDigest);
Assert.True(fs.TryReadAllText("/usr/bin/entrypoint.sh", out var textDescriptor, out var content));
Assert.Equal(descriptor.Path, textDescriptor.Path);
Assert.Contains("echo layer1", content);
Assert.False(fs.TryReadAllText("/opt/setup.sh", out _, out _));
var optEntries = fs.EnumerateDirectory("/opt");
Assert.DoesNotContain(optEntries, entry => entry.Path.EndsWith("setup.sh", StringComparison.Ordinal));
}
[Fact]
public void FromArchives_ResolvesSymlinkAndWhiteout()
{
var layer1Path = Path.Combine(_tempRoot, "layer1.tar");
var layer2Path = Path.Combine(_tempRoot, "layer2.tar");
CreateArchive(layer1Path, writer =>
{
var scriptEntry = new PaxTarEntry(TarEntryType.RegularFile, "usr/local/bin/start.sh");
scriptEntry.Mode = UnixFileMode.UserRead | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
scriptEntry.DataStream = new MemoryStream(Encoding.UTF8.GetBytes("#!/bin/sh\necho start\n"));
writer.WriteEntry(scriptEntry);
var oldScript = new PaxTarEntry(TarEntryType.RegularFile, "opt/old.sh");
oldScript.Mode = UnixFileMode.UserRead | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
oldScript.DataStream = new MemoryStream(Encoding.UTF8.GetBytes("echo old\n"));
writer.WriteEntry(oldScript);
});
CreateArchive(layer2Path, writer =>
{
var symlinkEntry = new PaxTarEntry(TarEntryType.SymbolicLink, "usr/bin/start.sh");
symlinkEntry.LinkName = "/usr/local/bin/start.sh";
writer.WriteEntry(symlinkEntry);
var whiteout = new PaxTarEntry(TarEntryType.RegularFile, "opt/.wh.old.sh");
whiteout.DataStream = new MemoryStream(Array.Empty<byte>());
writer.WriteEntry(whiteout);
});
var fs = LayeredRootFileSystem.FromArchives(new[]
{
new LayeredRootFileSystem.LayerArchive("sha256:base", layer1Path),
new LayeredRootFileSystem.LayerArchive("sha256:update", layer2Path)
});
Assert.True(fs.TryResolveExecutable("start.sh", new[] { "/usr/bin" }, out var descriptor));
Assert.Equal("/usr/local/bin/start.sh", descriptor.Path);
Assert.Equal("sha256:base", descriptor.LayerDigest);
Assert.True(fs.TryReadAllText("/usr/bin/start.sh", out var resolvedDescriptor, out var content));
Assert.Equal(descriptor.Path, resolvedDescriptor.Path);
Assert.Contains("echo start", content);
Assert.False(fs.TryReadAllText("/opt/old.sh", out _, out _));
}
[Fact]
public void FromArchives_ResolvesHardLinkContent()
{
var baseLayer = Path.Combine(_tempRoot, "base.tar");
var hardLinkLayer = Path.Combine(_tempRoot, "hardlink.tar");
CreateArchive(baseLayer, writer =>
{
var baseEntry = new PaxTarEntry(TarEntryType.RegularFile, "usr/bin/tool.sh");
baseEntry.Mode = UnixFileMode.UserRead | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
baseEntry.DataStream = new MemoryStream(Encoding.UTF8.GetBytes("#!/bin/sh\necho tool\n"));
writer.WriteEntry(baseEntry);
});
CreateArchive(hardLinkLayer, writer =>
{
var hardLink = new PaxTarEntry(TarEntryType.HardLink, "bin/tool.sh")
{
LinkName = "/usr/bin/tool.sh",
Mode = UnixFileMode.UserRead | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute
};
writer.WriteEntry(hardLink);
});
var fs = LayeredRootFileSystem.FromArchives(new[]
{
new LayeredRootFileSystem.LayerArchive("sha256:base", baseLayer),
new LayeredRootFileSystem.LayerArchive("sha256:hardlink", hardLinkLayer)
});
Assert.True(fs.TryReadAllText("/bin/tool.sh", out var descriptor, out var content));
Assert.Equal("/usr/bin/tool.sh", descriptor.Path);
Assert.Contains("echo tool", content);
}
private string CreateLayerDirectory(string name)
{
var path = Path.Combine(_tempRoot, name);
Directory.CreateDirectory(path);
return path;
}
private static void CreateArchive(string path, Action<TarWriter> writerAction)
{
using var stream = File.Create(path);
using var writer = new TarWriter(stream, leaveOpen: false);
writerAction(writer);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures
}
}
}

View File

@@ -0,0 +1,33 @@
using StellaOps.Scanner.EntryTrace.Parsing;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class ShellParserTests
{
[Fact]
public void Parse_ProducesDeterministicNodes()
{
const string script = """
#!/bin/sh
source /opt/init.sh
if [ -f /etc/profile ]; then
. /etc/profile
fi
run-parts /etc/entry.d
exec python -m app.main --flag
""";
var first = ShellParser.Parse(script);
var second = ShellParser.Parse(script);
Assert.Equal(first.Nodes.Length, second.Nodes.Length);
var actual = first.Nodes.Select(n => n.GetType().Name).ToArray();
var expected = new[] { nameof(ShellIncludeNode), nameof(ShellIfNode), nameof(ShellRunPartsNode), nameof(ShellExecNode) };
Assert.Equal(expected, actual);
var actualSecond = second.Nodes.Select(n => n.GetType().Name).ToArray();
Assert.Equal(expected, actualSecond);
}
}

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,180 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using StellaOps.Scanner.EntryTrace;
namespace StellaOps.Scanner.EntryTrace.Tests;
internal sealed class TestRootFileSystem : IRootFileSystem
{
private readonly Dictionary<string, FileEntry> _entries = new(StringComparer.Ordinal);
private readonly HashSet<string> _directories = new(StringComparer.Ordinal);
public TestRootFileSystem()
{
_directories.Add("/");
}
public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a")
{
var normalized = Normalize(path);
var directory = Path.GetDirectoryName(normalized);
if (!string.IsNullOrEmpty(directory))
{
_directories.Add(directory!);
}
_entries[normalized] = new FileEntry(normalized, content, executable, layer, IsDirectory: false);
}
public void AddDirectory(string path)
{
var normalized = Normalize(path);
_directories.Add(normalized);
}
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
{
if (name.Contains('/', StringComparison.Ordinal))
{
var normalized = Normalize(name);
if (_entries.TryGetValue(normalized, out var file) && file.IsExecutable)
{
descriptor = file.ToDescriptor();
return true;
}
descriptor = null!;
return false;
}
foreach (var prefix in searchPaths)
{
var candidate = Combine(prefix, name);
if (_entries.TryGetValue(candidate, out var file) && file.IsExecutable)
{
descriptor = file.ToDescriptor();
return true;
}
}
descriptor = null!;
return false;
}
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
{
var normalized = Normalize(path);
if (_entries.TryGetValue(normalized, out var file))
{
descriptor = file.ToDescriptor();
content = file.Content;
return true;
}
descriptor = null!;
content = string.Empty;
return false;
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalized = Normalize(path);
var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>();
foreach (var file in _entries.Values)
{
var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/");
if (string.Equals(directory, normalized, StringComparison.Ordinal))
{
builder.Add(file.ToDescriptor());
}
}
return builder.ToImmutable();
}
public bool DirectoryExists(string path)
{
var normalized = Normalize(path);
return _directories.Contains(normalized);
}
private static string Combine(string prefix, string name)
{
var normalizedPrefix = Normalize(prefix);
if (normalizedPrefix == "/")
{
return Normalize("/" + name);
}
return Normalize($"{normalizedPrefix}/{name}");
}
private static string Normalize(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
var text = path.Replace('\\', '/').Trim();
if (!text.StartsWith("/", StringComparison.Ordinal))
{
text = "/" + text;
}
var parts = new List<string>();
foreach (var part in text.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
if (part == ".")
{
continue;
}
if (part == "..")
{
if (parts.Count > 0)
{
parts.RemoveAt(parts.Count - 1);
}
continue;
}
parts.Add(part);
}
return "/" + string.Join('/', parts);
}
private sealed record FileEntry(string Path, string Content, bool IsExecutable, string? Layer, bool IsDirectory)
{
public RootFileDescriptor ToDescriptor()
{
var shebang = ExtractShebang(Content);
return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang);
}
}
private static string? ExtractShebang(string content)
{
if (string.IsNullOrEmpty(content))
{
return null;
}
using var reader = new StringReader(content);
var firstLine = reader.ReadLine();
if (firstLine is null)
{
return null;
}
if (!firstLine.StartsWith("#!", StringComparison.Ordinal))
{
return null;
}
return firstLine[2..].Trim();
}
}