feat(headless): establish portable Linux host boundary

Add the presentation-free acdream-headless executable, strict no-connect configuration validation, dependency and assembly guards, and a Windows/Ubuntu CI lane that builds and tests only the portable runtime closure.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-27 01:10:45 +02:00
parent 953c469cac
commit aada8a37c1
14 changed files with 613 additions and 8 deletions

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Headless\AcDream.Headless.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,134 @@
using System.Text.Json;
using System.Xml.Linq;
using System.Runtime.CompilerServices;
namespace AcDream.Headless.Tests;
public sealed class HeadlessDependencyBoundaryTests
{
private static readonly string[] ForbiddenDependencyPrefixes =
[
"AcDream.App",
"AcDream.UI.",
"Silk.NET",
"OpenAL",
"Arch",
"ImGui",
];
[Fact]
public void HeadlessAssemblyReferencesOnlyTheRuntimeProject()
{
string repositoryRoot = FindRepositoryRoot();
string projectPath = Path.Combine(
repositoryRoot,
"src",
"AcDream.Headless",
"AcDream.Headless.csproj");
var project = XDocument.Load(projectPath);
string projectDirectory = Path.GetDirectoryName(projectPath)!;
string[] actual = project
.Descendants("ProjectReference")
.Select(reference => reference.Attribute("Include")?.Value)
.Where(static include => !string.IsNullOrWhiteSpace(include))
.Select(include => Path.GetFullPath(Path.Combine(
projectDirectory,
include!.Replace(
'\\',
Path.DirectorySeparatorChar))))
.ToArray();
string expected = Path.Combine(
repositoryRoot,
"src",
"AcDream.Runtime",
"AcDream.Runtime.csproj");
Assert.Equal([expected], actual);
Assert.Empty(project.Descendants("PackageReference"));
}
[Fact]
public void HeadlessDependencyClosureHasNoPresentationOrBackendLibraries()
{
string depsPath = Path.ChangeExtension(
typeof(HeadlessDependencyBoundaryTests).Assembly.Location,
".deps.json");
using JsonDocument document =
JsonDocument.Parse(File.ReadAllText(depsPath));
string[] libraries = document.RootElement
.GetProperty("libraries")
.EnumerateObject()
.Select(static library => LibraryName(library.Name))
.ToArray();
Assert.DoesNotContain(libraries, IsForbidden);
}
[Fact]
public void HelpPathLoadsNoPresentationOrBackendAssembly()
{
using var output = new StringWriter();
using var error = new StringWriter();
Assert.Equal(
0,
HeadlessEntryPoint.Run(["--help"], output, error));
string[] loaded = AppDomain.CurrentDomain
.GetAssemblies()
.Select(static assembly =>
assembly.GetName().Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(loaded, IsForbidden);
}
private static string FindRepositoryRoot(
[CallerFilePath] string sourcePath = "")
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
{
continue;
}
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(
directory.FullName,
"AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the working or output directory.");
}
private static string LibraryName(string libraryIdentity)
{
int separatorIndex = libraryIdentity.IndexOf('/');
return separatorIndex < 0
? libraryIdentity
: libraryIdentity[..separatorIndex];
}
private static bool IsForbidden(string assemblyName) =>
ForbiddenDependencyPrefixes.Any(prefix =>
assemblyName.StartsWith(
prefix,
StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,109 @@
namespace AcDream.Headless.Tests;
public sealed class HeadlessEntryPointTests
{
[Theory]
[InlineData("--help")]
[InlineData("-h")]
public void HelpIsPresentationFreeAndSuccessful(string argument)
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
[argument],
output,
error);
Assert.Equal(0, exitCode);
Assert.Contains("acdream-headless", output.ToString());
Assert.Contains("Secrets are never accepted", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
{
using var file = TemporaryConfiguration.Create(
"""{"version":1,"sessions":[]}""");
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(0, exitCode);
Assert.Contains("0 session(s)", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Theory]
[InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")]
[InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")]
[InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")]
[InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")]
[InlineData("""{"version":1}""", "required properties")]
public void ValidateRejectsInvalidOrSecretShapedConfiguration(
string json,
string expectedError)
{
using var file = TemporaryConfiguration.Create(json);
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(3, exitCode);
Assert.Contains(
expectedError,
error.ToString(),
StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", error.ToString());
Assert.Equal(string.Empty, output.ToString());
}
[Fact]
public void UnknownCommandReturnsUsageError()
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["connect", "--password", "secret"],
output,
error);
Assert.Equal(2, exitCode);
Assert.DoesNotContain("secret", error.ToString());
Assert.Equal(string.Empty, output.ToString());
}
private sealed class TemporaryConfiguration : IDisposable
{
private TemporaryConfiguration(string path)
{
Path = path;
}
internal string Path { get; }
internal static TemporaryConfiguration Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-headless-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryConfiguration(path);
}
public void Dispose()
{
File.Delete(Path);
}
}
}

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Xml.Linq;
using System.Runtime.CompilerServices;
namespace AcDream.Runtime.Tests;
@ -59,7 +60,11 @@ public sealed class RuntimeDependencyBoundaryTests
.Descendants("ProjectReference")
.Select(reference => reference.Attribute("Include")?.Value)
.Where(static include => !string.IsNullOrWhiteSpace(include))
.Select(include => Path.GetFullPath(Path.Combine(projectDirectory, include!)))
.Select(include => Path.GetFullPath(Path.Combine(
projectDirectory,
include!.Replace(
'\\',
Path.DirectorySeparatorChar))))
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
var expectedReferences = new[]
@ -94,21 +99,38 @@ public sealed class RuntimeDependencyBoundaryTests
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
}
private static string FindRepositoryRoot()
private static string FindRepositoryRoot(
[CallerFilePath] string sourcePath = "")
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
string[] starts =
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
{
return directory.FullName;
continue;
}
directory = directory.Parent;
var directory = new DirectoryInfo(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(
directory.FullName,
"AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
$"Could not find AcDream.slnx above {AppContext.BaseDirectory}.");
"Could not find AcDream.slnx above the source, working, or output directory.");
}
private static string LibraryName(string libraryIdentity)