diff --git a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs index 3918132ded49..2f7e097e6e48 100644 --- a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs +++ b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs @@ -14,36 +14,41 @@ namespace Files.Platform.Linux.Launching /// Builds an interactive elevation command without interpreting file paths as code. public sealed class RootTerminalResolver { - private sealed record Configuration(string Tool, string? Shell = null, bool KeepWorkingDirectory = false); + private sealed record Configuration(string Tool, string Shell, bool KeepWorkingDirectory = false); private readonly Func disabled; private readonly Lazy configuration; public RootTerminalResolver() : this( new SystemToolResolver(new ElevationPathChecker(new StatxFileOwnershipInspector(), ProcessIdentityNative.CurrentUserId)), - new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal) { } + new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal, + rootShell: () => ElevationNative.LoginShell(0)) { } + // The shell comes from root's passwd entry, not the caller's $SHELL, so root's own shell and config are used. public RootTerminalResolver(ITrustedToolResolver tools, IExecutableLocator locator, Func environment, - Func disabled, Func? version = null) + Func disabled, Func? version = null, Func? rootShell = null) { this.disabled = disabled; configuration = new Lazy(() => { - if (tools.Resolve("run0") is { } run0) return new(run0); - if (tools.Resolve("sudo") is { } sudo) return new(sudo); + var shell = rootShell?.Invoke() is { } configured && configured.StartsWith('/') ? locator.Locate(configured) : null; + shell ??= locator.Locate("/bin/sh"); + if (shell is null) return null; + if (tools.Resolve("run0") is { } run0) return new(run0, shell); + if (tools.Resolve("sudo") is { } sudo) return new(sudo, shell); if (tools.Resolve("pkexec") is not { } pkexec) return null; - var shell = environment("SHELL"); - var executable = shell is not null && shell.StartsWith('/') ? locator.Locate(shell) : null; - executable ??= locator.Locate("/bin/sh"); - return executable is null ? null : new(pkexec, executable, SupportsKeepCwd((version ?? ReadVersion)(pkexec))); + return new(pkexec, shell, SupportsKeepCwd((version ?? ReadVersion)(pkexec))); }); } public IReadOnlyList? Resolve(string folder) { if (disabled() || configuration.Value is not { } command) return null; - if (command.Shell is { } shell) - return command.KeepWorkingDirectory ? [command.Tool, "--keep-cwd", shell] : [command.Tool, shell]; - return Path.GetFileName(command.Tool) == "run0" ? [command.Tool, "--chdir=" + folder] : [command.Tool, "-s"]; + return Path.GetFileName(command.Tool) switch + { + "run0" => [command.Tool, "--chdir=" + folder, "--", command.Shell], + "sudo" => [command.Tool, "--", command.Shell], + _ => command.KeepWorkingDirectory ? [command.Tool, "--keep-cwd", command.Shell] : [command.Tool, command.Shell], + }; } public static bool SupportsKeepCwd(string? output) diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index dae58fdab7f2..b6442d0c82a0 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -90,6 +90,24 @@ internal static string HomeDirectory(uint uid) throw new IOException("Root account exceeds safety limits."); } + internal static string? LoginShell(uint uid) + { + for (var length = 16384; length <= 1048576; length *= 2) + { + var buffer = new byte[length]; + var entry = new Passwd(); + fixed (byte* pointer = buffer) + { + var error = GetPasswd(uid, ref entry, pointer, (nuint)length, out var result); + if (error == 34) continue; // ERANGE + if (error != 0 || result == 0 || entry.Uid != uid || entry.Shell == 0) + return null; + return Marshal.PtrToStringUTF8(entry.Shell); + } + } + return null; + } + internal static void SetOwnership(int fd, uint uid, uint gid, uint mode) { // An ACL inherited from the destination's default ACL would gain an effective mask from fchmod. diff --git a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs index 59c18614784d..4ca9590f1620 100644 --- a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs +++ b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs @@ -83,7 +83,8 @@ private static (LinuxLauncherService Service, RecordingStarter Starter) Create(X new LinuxApplicationRegistry(fx.Directories, culture, locator), starter, new TerminalResolver(locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null), - new RootTerminalResolver(new FakeTools(executables), locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null, () => false, _ => "pkexec version 0.121")); + new RootTerminalResolver(new FakeTools(executables), locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null, () => false, _ => "pkexec version 0.121", + () => env is not null && env.TryGetValue("ROOT_SHELL", out var shell) ? shell : null)); return (service, starter); } @@ -189,13 +190,13 @@ public async Task RootTerminal_PreservesLiteralDirectoryAndUsesPreferredTool(str using var fx = new XdgFixture(); var folder = Path.Combine(fx.Home, "a 'quoted' $(touch nope); dir"); Directory.CreateDirectory(folder); - var (service, starter) = Create(fx, new() { ["TERMINAL"] = terminal }, terminal, "run0", "sudo", "pkexec"); + var (service, starter) = Create(fx, new() { ["TERMINAL"] = terminal }, terminal, "run0", "sudo", "pkexec", "/bin/sh"); Assert.IsTrue(service.CanOpenTerminalAsRoot); Assert.IsTrue(await service.OpenTerminalAsRootAsync(folder)); var launch = starter.Launches.Single(); Assert.AreEqual(terminal, launch.FileName); Assert.AreEqual(folder, launch.WorkingDirectory); - CollectionAssert.AreEqual(new[] { "/usr/bin/run0", "--chdir=" + folder }, launch.Arguments.TakeLast(2).ToArray()); + CollectionAssert.AreEqual(new[] { "/usr/bin/run0", "--chdir=" + folder, "--", "/usr/bin//bin/sh" }, launch.Arguments.TakeLast(4).ToArray()); Assert.IsFalse(launch.Arguments.Contains("-c")); } @@ -203,13 +204,13 @@ public async Task RootTerminal_PreservesLiteralDirectoryAndUsesPreferredTool(str public async Task RootTerminal_FallbacksRefusalsAndPackageGate() { using var fx = new XdgFixture(); - var (sudo, starter) = Create(fx, null, "konsole", "sudo", "pkexec"); + var (sudo, starter) = Create(fx, null, "konsole", "sudo", "pkexec", "/bin/sh"); Assert.IsTrue(await sudo.OpenTerminalAsRootAsync(fx.Home)); - CollectionAssert.AreEqual(new[] { "--workdir", fx.Home, "-e", "/usr/bin/sudo", "-s" }, starter.Launches.Single().Arguments.ToArray()); - var (pkexec, pkStarter) = Create(fx, new() { ["SHELL"] = "/bin/zsh" }, "konsole", "pkexec", "/bin/zsh"); + CollectionAssert.AreEqual(new[] { "--workdir", fx.Home, "-e", "/usr/bin/sudo", "--", "/usr/bin//bin/sh" }, starter.Launches.Single().Arguments.ToArray()); + var (pkexec, pkStarter) = Create(fx, new() { ["ROOT_SHELL"] = "/bin/zsh" }, "konsole", "pkexec", "/bin/zsh"); Assert.IsTrue(await pkexec.OpenTerminalAsRootAsync(fx.Home)); CollectionAssert.AreEqual(new[] { "/usr/bin/pkexec", "--keep-cwd", "/usr/bin//bin/zsh" }, pkStarter.Launches.Single().Arguments.TakeLast(3).ToArray()); - var (fallback, fallbackStarter) = Create(fx, new() { ["SHELL"] = "sh -c code" }, "konsole", "pkexec", "/bin/sh"); + var (fallback, fallbackStarter) = Create(fx, new() { ["SHELL"] = "/bin/fish", ["ROOT_SHELL"] = "sh -c code" }, "konsole", "pkexec", "/bin/sh", "/bin/fish"); Assert.IsTrue(await fallback.OpenTerminalAsRootAsync(fx.Home)); Assert.AreEqual("/usr/bin//bin/sh", fallbackStarter.Launches.Single().Arguments.Last()); Assert.IsFalse(await sudo.OpenTerminalAsRootAsync("relative"));