Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions src/Files.Platform.Linux/Launching/RootTerminalResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,41 @@ namespace Files.Platform.Linux.Launching
/// <summary>Builds an interactive elevation command without interpreting file paths as code.</summary>
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<bool> disabled;
private readonly Lazy<Configuration?> 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<string, string?> environment,
Func<bool> disabled, Func<string, string?>? version = null)
Func<bool> disabled, Func<string, string?>? version = null, Func<string?>? rootShell = null)
{
this.disabled = disabled;
configuration = new Lazy<Configuration?>(() =>
{
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<string>? 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)
Expand Down
18 changes: 18 additions & 0 deletions src/Files.Platform.Linux/Native/ElevationNative.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 8 additions & 7 deletions tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -189,27 +190,27 @@ 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"));
}

[TestMethod]
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"));
Expand Down
Loading