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"));