diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml new file mode 100644 index 00000000..250a92d4 --- /dev/null +++ b/.github/workflows/headless-portability.yml @@ -0,0 +1,97 @@ +name: Headless portability + +on: + pull_request: + paths: + - ".github/workflows/headless-portability.yml" + - "AcDream.slnx" + - "src/AcDream.Core/**" + - "src/AcDream.Core.Net/**" + - "src/AcDream.Content/**" + - "src/AcDream.Plugin.Abstractions/**" + - "src/AcDream.Runtime/**" + - "src/AcDream.Headless/**" + - "tests/AcDream.Core.Tests/**" + - "tests/AcDream.Core.Net.Tests/**" + - "tests/AcDream.Content.Tests/**" + - "tests/AcDream.Runtime.Tests/**" + - "tests/AcDream.Headless.Tests/**" + push: + paths: + - ".github/workflows/headless-portability.yml" + - "AcDream.slnx" + - "src/AcDream.Core/**" + - "src/AcDream.Core.Net/**" + - "src/AcDream.Content/**" + - "src/AcDream.Plugin.Abstractions/**" + - "src/AcDream.Runtime/**" + - "src/AcDream.Headless/**" + - "tests/AcDream.Core.Tests/**" + - "tests/AcDream.Core.Net.Tests/**" + - "tests/AcDream.Content.Tests/**" + - "tests/AcDream.Runtime.Tests/**" + - "tests/AcDream.Headless.Tests/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-headless: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Build presentation-free closure + shell: pwsh + run: | + $projects = @( + "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", + "src/AcDream.Core/AcDream.Core.csproj", + "src/AcDream.Core.Net/AcDream.Core.Net.csproj", + "src/AcDream.Content/AcDream.Content.csproj", + "src/AcDream.Runtime/AcDream.Runtime.csproj", + "src/AcDream.Headless/AcDream.Headless.csproj" + ) + foreach ($project in $projects) { + dotnet build $project -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + # AcDream.Core.Tests still contains historical App integration fixtures, + # so it is deliberately not a member of this no-App restore lane. Core is + # built directly above and exercised through every portable downstream + # test project below; the complete solution lane retains Core.Tests. + - name: Test presentation-free closure + shell: pwsh + run: | + $projects = @( + "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", + "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", + "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", + "tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" + ) + foreach ($project in $projects) { + dotnet test $project -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + - name: Verify CLI without connecting + shell: pwsh + run: | + dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}' + dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/AcDream.slnx b/AcDream.slnx index 5fc96499..b9ed96d4 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -6,6 +6,7 @@ + @@ -23,6 +24,7 @@ + diff --git a/src/AcDream.Headless/AcDream.Headless.csproj b/src/AcDream.Headless/AcDream.Headless.csproj new file mode 100644 index 00000000..accb8038 --- /dev/null +++ b/src/AcDream.Headless/AcDream.Headless.csproj @@ -0,0 +1,22 @@ + + + Exe + acdream-headless + net10.0 + enable + enable + latest + true + + + + + + + + + + + diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs new file mode 100644 index 00000000..0d5858b9 --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Headless.Configuration; + +internal sealed class HeadlessConfiguration +{ + [JsonRequired] + public int Version { get; init; } + + [JsonRequired] + public List Sessions { get; init; } = []; +} + +internal sealed class HeadlessSessionDescriptor +{ + [JsonRequired] + public string Id { get; init; } = string.Empty; +} diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs new file mode 100644 index 00000000..f257b1dc --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs @@ -0,0 +1,9 @@ +namespace AcDream.Headless.Configuration; + +internal sealed class HeadlessConfigurationException : Exception +{ + internal HeadlessConfigurationException(string message) + : base(message) + { + } +} diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs new file mode 100644 index 00000000..0141712e --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Headless.Configuration; + +internal static class HeadlessConfigurationLoader +{ + private const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions Options = new() + { + AllowTrailingCommas = false, + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + internal static HeadlessConfiguration Load(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + using FileStream stream = File.OpenRead(fullPath); + HeadlessConfiguration? configuration = + JsonSerializer.Deserialize( + stream, + Options); + + if (configuration is null) + { + throw new HeadlessConfigurationException( + "The configuration document is empty."); + } + + if (configuration.Version != CurrentVersion) + { + throw new HeadlessConfigurationException( + $"Unsupported configuration version {configuration.Version}; " + + $"expected {CurrentVersion}."); + } + + if (configuration.Sessions is null) + { + throw new HeadlessConfigurationException( + "sessions must be an array."); + } + + var sessionIds = new HashSet(StringComparer.Ordinal); + foreach (HeadlessSessionDescriptor? session in configuration.Sessions) + { + if (session is null + || string.IsNullOrWhiteSpace(session.Id)) + { + throw new HeadlessConfigurationException( + "Every session requires a non-empty id."); + } + + if (!sessionIds.Add(session.Id)) + { + throw new HeadlessConfigurationException( + $"Duplicate session id '{session.Id}'."); + } + } + + return configuration; + } +} diff --git a/src/AcDream.Headless/HeadlessAssemblyMarker.cs b/src/AcDream.Headless/HeadlessAssemblyMarker.cs new file mode 100644 index 00000000..2854b49d --- /dev/null +++ b/src/AcDream.Headless/HeadlessAssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace AcDream.Headless; + +/// +/// Identifies the presentation-free production host assembly. +/// +internal static class HeadlessAssemblyMarker; diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs new file mode 100644 index 00000000..5f3135b2 --- /dev/null +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -0,0 +1,82 @@ +using AcDream.Headless.Configuration; + +namespace AcDream.Headless; + +internal static class HeadlessEntryPoint +{ + private const string HelpText = + """ + acdream-headless + + Usage: + acdream-headless --help + acdream-headless validate --config + + Commands: + validate Validate a versioned headless configuration without connecting. + + Secrets are never accepted on the command line. + """; + + internal static int Run( + IReadOnlyList arguments, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (arguments.Count == 0 + || IsHelp(arguments[0])) + { + output.WriteLine(HelpText); + return (int)HeadlessExitCode.Success; + } + + if (!string.Equals( + arguments[0], + "validate", + StringComparison.Ordinal)) + { + error.WriteLine("Unknown command. Run --help for usage."); + return (int)HeadlessExitCode.UsageError; + } + + if (arguments.Count != 3 + || !string.Equals( + arguments[1], + "--config", + StringComparison.Ordinal)) + { + error.WriteLine( + "validate requires exactly: --config "); + return (int)HeadlessExitCode.UsageError; + } + + try + { + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(arguments[2]); + output.WriteLine( + $"Configuration valid: version {configuration.Version}, " + + $"{configuration.Sessions.Count} session(s)."); + return (int)HeadlessExitCode.Success; + } + catch (Exception exception) + when (exception is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Text.Json.JsonException + or HeadlessConfigurationException) + { + error.WriteLine($"Configuration invalid: {exception.Message}"); + return (int)HeadlessExitCode.ConfigurationError; + } + } + + private static bool IsHelp(string argument) => + string.Equals(argument, "--help", StringComparison.Ordinal) + || string.Equals(argument, "-h", StringComparison.Ordinal); +} diff --git a/src/AcDream.Headless/HeadlessExitCode.cs b/src/AcDream.Headless/HeadlessExitCode.cs new file mode 100644 index 00000000..0e199e8f --- /dev/null +++ b/src/AcDream.Headless/HeadlessExitCode.cs @@ -0,0 +1,8 @@ +namespace AcDream.Headless; + +internal enum HeadlessExitCode +{ + Success = 0, + UsageError = 2, + ConfigurationError = 3, +} diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs new file mode 100644 index 00000000..2ceddadf --- /dev/null +++ b/src/AcDream.Headless/Program.cs @@ -0,0 +1,3 @@ +using AcDream.Headless; + +return HeadlessEntryPoint.Run(args, Console.Out, Console.Error); diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj new file mode 100644 index 00000000..8a17a145 --- /dev/null +++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + latest + true + + + + + + + + + + + + + + + + + diff --git a/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs b/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs new file mode 100644 index 00000000..c71de634 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs @@ -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)); +} diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs new file mode 100644 index 00000000..010da026 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -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); + } + } +} diff --git a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs index 949dc51d..262eb607 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs @@ -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)