fix(launcher): require executable Linux hosts

This commit is contained in:
Erik 2026-08-14 19:12:32 +02:00
parent 10a712d66b
commit ae2cbbee8c
5 changed files with 134 additions and 10 deletions

View file

@ -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

View file

@ -12,12 +12,14 @@ namespace AcDream.Launcher.Core.Orchestration;
public sealed class LauncherExecutableSet
{
private readonly Func<string, bool> _fileExists;
private readonly Func<string, bool> _hasUnixExecutePermission;
public LauncherExecutableSet(
string graphicalHostPath,
string headlessHostPath,
string? workingDirectory = null,
Func<string, bool>? fileExists = null)
Func<string, bool>? fileExists = null,
Func<string, bool>? 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;
}
}
}

View file

@ -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<LauncherOperationException>(() =>
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<LauncherOperationException>(() =>
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json"));
Assert.Throws<LauncherOperationException>(() =>
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);
}
}
}

View file

@ -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,

View file

@ -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)