From ae2cbbee8c06aa98b71771980dca2543077a616c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:12:32 +0200 Subject: [PATCH] fix(launcher): require executable Linux hosts --- .github/workflows/headless-portability.yml | 7 ++ .../Orchestration/LauncherExecutableSet.cs | 53 ++++++++++--- .../LauncherExecutableSetTests.cs | 76 +++++++++++++++++++ .../LauncherOrchestratorTests.cs | 3 +- .../LauncherProjectBoundaryTests.cs | 5 ++ 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 3fae2923..801f3476 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -135,6 +135,13 @@ jobs: dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Verify Linux headless host executable permission + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless + portable-launcher: strategy: fail-fast: false diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index a02496ce..ef4635c5 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -12,12 +12,14 @@ namespace AcDream.Launcher.Core.Orchestration; public sealed class LauncherExecutableSet { private readonly Func _fileExists; + private readonly Func _hasUnixExecutePermission; public LauncherExecutableSet( string graphicalHostPath, string headlessHostPath, string? workingDirectory = null, - Func? fileExists = null) + Func? fileExists = null, + Func? hasUnixExecutePermission = null) { ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); @@ -25,6 +27,8 @@ public sealed class LauncherExecutableSet HeadlessHostPath = headlessHostPath; WorkingDirectory = workingDirectory; _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; } public string GraphicalHostPath { get; } @@ -38,17 +42,25 @@ public sealed class LauncherExecutableSet string path = mode == LaunchMode.Headless ? HeadlessHostPath : GraphicalHostPath; - if (_fileExists(path)) - { - return LauncherCapability.Available; - } - string host = mode == LaunchMode.Headless ? "headless host" : "graphical client"; - return LauncherCapability.Unavailable( - $"The co-deployed {host} is missing at '{path}'. Reinstall or update " - + "the client before launching."); + if (!_fileExists(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} is missing at '{path}'. Reinstall or update " + + "the client before launching."); + } + + if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} at '{path}' exists but is not executable. " + + "Restore its executable permission (for example, chmod +x) or " + + "reinstall/update the client before launching."); + } + + return LauncherCapability.Available; } public LauncherProcessSpec CreatePlaySpec( @@ -99,4 +111,27 @@ public sealed class LauncherExecutableSet capability.Reason ?? "The selected launcher host is unavailable."); } } + + private static bool HasUnixExecutePermission(string path) + { + if (!OperatingSystem.IsLinux()) + { + return true; + } + + try + { + const UnixFileMode executeBits = + UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & executeBits) != 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Fail closed if the file vanished or its metadata cannot be read + // after the existence check. The next capability refresh retries. + return false; + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs index c275cc93..3d146ffd 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs @@ -27,6 +27,8 @@ public sealed class LauncherExecutableSetTests : IDisposable string headless = Path.Combine(_root, "acdream-headless" + suffix); File.WriteAllText(graphical, string.Empty); File.WriteAllText(headless, string.Empty); + MakeExecutableOnLinux(graphical); + MakeExecutableOnLinux(headless); LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root); @@ -64,4 +66,78 @@ public sealed class LauncherExecutableSetTests : IDisposable Assert.Throws(() => set.CreateProbeSpec("session.json")); } + + [Fact] + public void LinuxRequiresExecutePermissionForBothCoDeployedHosts() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + Directory.CreateDirectory(_root); + string graphical = Path.Combine(_root, "AcDream.App"); + string headless = Path.Combine(_root, "acdream-headless"); + File.WriteAllText(graphical, string.Empty); + File.WriteAllText(headless, string.Empty); + UnixFileMode notExecutable = UnixFileMode.UserRead | UnixFileMode.UserWrite + | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + File.SetUnixFileMode(graphical, notExecutable); + File.SetUnixFileMode(headless, notExecutable); + LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root); + + LauncherCapability gui = set.GetAvailability(LaunchMode.Gui); + LauncherCapability headlessCapability = + set.GetAvailability(LaunchMode.Headless); + + Assert.False(gui.IsAvailable); + Assert.Contains("not executable", gui.Reason, StringComparison.Ordinal); + Assert.Contains("chmod +x", gui.Reason, StringComparison.Ordinal); + Assert.False(headlessCapability.IsAvailable); + Assert.Contains("not executable", headlessCapability.Reason, StringComparison.Ordinal); + Assert.Throws(() => + set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json")); + Assert.Throws(() => + set.CreateProbeSpec("session.json")); + + MakeExecutableOnLinux(graphical); + MakeExecutableOnLinux(headless); + + Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable); + Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable); + } + + [Fact] + public void WindowsPreservesExistenceOnlyAvailability() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var set = new LauncherExecutableSet( + "graphical.exe", + "headless.exe", + fileExists: _ => true, + hasUnixExecutePermission: _ => false); + + Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable); + Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable); + } + + private static void MakeExecutableOnLinux(string path) + { + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 14cb64e5..57ab95ed 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -518,7 +518,8 @@ public sealed class LauncherOrchestratorTests : IDisposable executables ?? new LauncherExecutableSet( "gui-host", "headless-host", - fileExists: _ => true), + fileExists: _ => true, + hasUnixExecutePermission: _ => true), new LauncherInstallRecord("dats", "pak"), platform ?? WindowsCapabilities(), configService, diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs index 456b799b..b2dc593a 100644 --- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs @@ -116,6 +116,11 @@ public sealed class LauncherProjectBoundaryTests Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal); Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal); Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal); + Assert.Contains( + "test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless", + workflow, + StringComparison.Ordinal); + Assert.Contains("test -x \"$root/AcDream.App\"", workflow, StringComparison.Ordinal); } private static string EvaluateProperty(string projectPath, string property)