From 8eea32fd215984e747d23dd7dd516e86292a29e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 11:43:31 +0300 Subject: [PATCH 1/3] Fix KDE service menus and add root terminal action Skip only shell-requiring service-menu actions instead of whole files, strip submenu mnemonics, honour MIME lists in ServiceTypes, and add a localized 'Open in terminal as root' action (run0, sudo, then pkexec). Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/threat-model-elevation.md | 4 +- docs/linux-port/threat-model-launching.md | 21 +- ...entPageContextFlyoutFactory.RootActions.cs | 18 +- src/Files.App/Helpers/RootActionsHelper.cs | 9 +- src/Files.App/Strings/en-US/Resources.resw | 3 + .../Launching/ILauncherService.cs | 6 + .../Elevation/PkexecElevationService.cs | 4 +- .../Elevation/RootActionsAvailability.cs | 16 + .../Launching/LinuxLauncherService.cs | 17 +- .../Launching/RootTerminalResolver.cs | 38 ++ .../Launching/TerminalResolver.cs | 7 +- .../Mime/DesktopEntryParser.cs | 9 +- .../Mime/LinuxServiceMenuService.cs | 3 +- .../Mime/ServiceMenuParser.cs | 43 +- .../Files.Platform.Tests.csproj | 3 + .../Launching/LauncherServiceTests.cs | 52 ++- .../10-rootactions-folders.desktop | 370 ++++++++++++++++ .../ServiceMenus/11-rootactions-files.desktop | 401 ++++++++++++++++++ .../Mime/Fixtures/ServiceMenus/README.md | 9 + .../com.mitchellh.ghostty.desktop | 11 + .../ServiceMenus/converseen_import.desktop | 29 ++ .../Fixtures/ServiceMenus/installfont.desktop | 64 +++ .../Fixtures/ServiceMenus/konsolerun.desktop | 126 ++++++ .../Mime/Fixtures/ServiceMenus/mat2.desktop | 13 + .../Mime/ServiceMenuTests.cs | 94 +++- 25 files changed, 1341 insertions(+), 29 deletions(-) create mode 100644 src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs create mode 100644 src/Files.Platform.Linux/Launching/RootTerminalResolver.cs create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/10-rootactions-folders.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/11-rootactions-files.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/converseen_import.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/installfont.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/konsolerun.desktop create mode 100644 tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/mat2.desktop diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index 75e2362d0584..66ee28589bdb 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -1,6 +1,6 @@ # Threat model: root actions (Linux) -Root actions offers delete, rename and paste (copy or move). It stays hidden in virtual locations and in AppImage/Flatpak, and unless pkexec, the native helper and its policy are installed at trusted system locations. AppImage/Flatpak neither install nor execute a host elevation helper. +Root file operations offer delete, rename and paste (copy or move). It stays hidden in virtual locations and in AppImage/Flatpak, and unless pkexec, the native helper and its policy are installed at trusted system locations. AppImage/Flatpak neither install nor execute a host elevation helper. ## Privilege boundary @@ -52,3 +52,5 @@ Non-root tests run HelperEngine directly against private temp trees, substitutin ## Not implemented Open terminal as root and edit as root remain outside this helper's protocol. Symlink copying, timestamp/permission preservation, transactional rollback and concurrent-writer snapshots are unsupported. + +The submenu also offers an interactive root terminal independently of helper installation, using the same packaging disable gate. Its broader root-shell authorization and terminal launch behavior are documented in [threat-model-launching.md](threat-model-launching.md#built-in-root-terminal). diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md index 620702827c93..d137bc0ad628 100644 --- a/docs/linux-port/threat-model-launching.md +++ b/docs/linux-port/threat-model-launching.md @@ -11,7 +11,7 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar | 1 | Decision from file bytes (ELF / `#!` magic), not name or MIME glob | `OpenDecision.Sniff/Decide` | `OpenDecisionTests` | | 2 | Binaries (+x) always ask Run/Cancel with full path; scripts (+x) ask Run/Display/Cancel; ELF without +x is refused | `OpenDecision`, `ExecutePlanAsync` | `ContentDecidesNeverTheName` | | 3 | `.desktop`: silent only if under an XDG `applications` dir; otherwise always ask showing the exact argv; +x is not trust | `OpenDecision`, `ExecutePlanAsync` | `Desktop_Trusted...` | -| 4 | Strict `.desktop` parse: NUL rejected anywhere in the file; control chars rejected inside `[Desktop Entry]` (including whitespace-padded group headers such as `\v[Foo]`) and in Exec; one `[Desktop Entry]` group, no duplicate keys, Type+Exec required, no localized `Exec[..]/Type[..]/Terminal[..]/Path[..]/TryExec[..]`; on the launcher path other groups and malformed lines are ignored, service menus stay strict everywhere; invalid = refused (never given to a handler) | `DesktopEntryParser.ParseStrict` | `StrictParse_*` | +| 4 | Strict `.desktop` parse: NUL rejected anywhere in the file; control chars rejected inside `[Desktop Entry]` (including whitespace-padded group headers such as `\v[Foo]`) and in Exec; one `[Desktop Entry]` group, no duplicate keys, Type+Exec required, no localized `Exec[..]/Type[..]/Terminal[..]/Path[..]/TryExec[..]`; on the launcher path other groups and malformed lines are ignored, service menus stay strict everywhere except repeated, identical Name/Submenu display keys; invalid = refused (never given to a handler) | `DesktopEntryParser.ParseStrict` | `StrictParse_*` | | 5 | Display equals exec: the argv is computed once (`DesktopExecExpander.Expand`), shown (control chars escaped), and passed unchanged to `ILauncherService.RunCommandAsync`; nothing is re-parsed or re-expanded after the dialog | `LinuxOpenPlan.Argv`, `RunCommandAsync` | `RunCommand_StartsExactlyTheDisplayedArgv...`, `Exec_ParsedOnce...` | | 6 | Dialog answer maps to exactly the described follow-up; Cancel/close/dialog failure never opens anything. "Display" opens the file in an explicitly chosen `text/plain` handler, never the file's own MIME default | `OpenDecision.Resolve`, `DisplayAsTextAsync` | `DialogAnswerMapsToExactlyTheDescribedFollowUp` | | 7 | TOCTOU: identity (dev, ino, mode, size, mtime; statx without following the final symlink) captured before the dialog and re-checked after it and before every start, including shared default-app launches | `FileIdentity`, `LinuxOpenPlan.StillValid` | `FileIdentity_DetectsChange` | @@ -20,17 +20,20 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar | 10 | More than 5 files opened at once asks first; files needing a gate are processed one by one, never in the bulk default-app launch | `OpenFilesLinuxAsync` | (UI path, covered by 1-9) | | 11 | Dry-run seam (`FILES_LAUNCH_DRYRUN`) so automated runs spawn nothing | `DryRunProcessStarter` | n/a | | 12 | Drop items onto an executable: same plan as gates 1-2 (only confirmable binaries/scripts); the dialog shows the full argv (target plus every dropped path, `DisplaySanitizer.FullArguments`, refused if too large) and exactly that argv is run after the identity re-check | `NavigationHelpers.RunWithItemsLinuxAsync` | `OnlyConfirmedActionsMayRunAFile`, `DisplaySanitizerTests` | -| 13 | KDE service menus: every invocation (including system menus) uses `LaunchDesktopConfirm` and the existing launcher plan/dialog. User menus never gain trust from their directory or execute bit. Strict group/key validation is shared with the desktop parser; Type=Service and each declared action's Name/Exec are required | `ServiceMenuParser`, `RunServiceMenuLinuxAsync`, `ExecutePlanAsync` | `ServiceMenuTests` | +| 13 | KDE service menus: every invocation (including system menus) uses `LaunchDesktopConfirm` and the existing launcher plan/dialog. User menus never gain trust from their directory or execute bit. Strict group/key validation is shared with the desktop parser; Type=Service and each declared action's Name/Exec are required; unsupported Exec actions are skipped individually | `ServiceMenuParser`, `RunServiceMenuLinuxAsync`, `ExecutePlanAsync` | `ServiceMenuTests` | | 14 | Service Exec: tokenize first, then substitute `%f/%F/%u/%U` only as complete argv elements; no shell or embedded target-code expansion. Explicit shells, shell syntax, unknown codes and oversized argv are refused. Every argv is shown through `DisplaySanitizer.FullArguments`, kept unchanged and identity-checked before its start; cancelling stops the remaining invocations | `DesktopExecExpander.ExpandServiceMenu`, `ServiceMenuLaunchPlan` | `Expansion_*`, `Plan_PinsCodeBeforeDialog_*` | -| 15 | Service discovery: user entries override system entries by basename (hidden/invalid overrides included); every selected MIME/protocol and URL-count restriction must match. Scan at most 512 directory entries and return at most 256 actions for at most 256 selected targets; each file uses the existing pinned regular-file reader's 64 KiB/1000-line/4096-byte-line/200-key limits. Final symlinks, devices and FIFOs are refused | `LinuxServiceMenuService`, `DesktopEntryDisplay.ReadLinesBounded` | `Scan_*`, `Filter_*` | +| 15 | Service discovery: user entries override system entries by basename (hidden/invalid overrides included); every selected MIME/protocol and URL-count restriction must match. Scan at most 512 directory entries and return at most 256 actions for at most 256 selected targets; each file uses the existing pinned regular-file reader's 64 KiB/1000-line/4096-byte-line limits, with a service-specific 1000-key cap for translated multi-action menus (application launchers retain 200 keys). Final symlinks, devices and FIFOs are refused | `LinuxServiceMenuService`, `DesktopEntryDisplay.ReadLinesBounded` | `Scan_*`, `Filter_*` | Service menus are read from XDG data directories' `kio/servicemenus` and legacy `kservices5/ServiceMenus`, including `~/.local/share`. Actions enter the existing Linux **Show more options** flow (or render inline when the user enables that existing menu setting). Localized `X-KDE-Submenu` groups are preserved. `X-KDE-Priority=TopLevel` entries sort first inside that flow, followed by `Important`, then normal entries. -MIME filtering includes aliases, parent types, type globs, and KDE's `all/all` / `all/allfiles`; required URL counts and protocol lists accept KDE comma lists as well as semicolons. +MIME filtering combines `MimeType`, `ServiceTypes` and `X-KDE-ServiceTypes`, discarding only the `KonqPopupMenu/Plugin` marker. It includes aliases, parent types, type globs, KDE's `all/all` / `all/allfiles`, `application/octet-stream` for all files and `inode/directory` for folders. Required, minimum and maximum URL counts and protocol lists are applied to every selection; lists accept KDE commas as well as semicolons. Malformed count constraints fail closed. +`X-KDE-Submenu` loses Qt mnemonic markers (`&Root` becomes `Root`, `&&` becomes `&`). Repeated identical Name/Submenu display keys are accepted for distro compatibility; differing duplicates and all duplicate execution keys still fail closed. +Nonempty `X-KDE-AuthorizeAction`, `X-KDE-ShowIfRunning` and `X-KDE-ShowIfDBusCall` hide the affected menu or individual action: Files cannot evaluate KDE kiosk authorization, process identity or DBus predicates and never calls those predicates during discovery. This conservatively hides `konsolerun` rather than bypassing `shell_access` policy. See [KDE's service-menu format](https://develop.kde.org/docs/apps/dolphin/service-menus/). -Compatibility limits: commands needing a shell, inline scripts containing target field codes, and extra KDE field codes such as `%D` are refused rather than interpreted. +Compatibility limits: unsupported actions are removed individually. Commands needing a shell, inline scripts containing target field codes, and extra KDE field codes such as `%D` are refused rather than interpreted. +The exact shipped `com.mitchellh.ghostty.desktop` Exec (`ghostty --working-directory=%F --gtk-single-instance=false`) is normalized to `env --chdir %f ghostty --working-directory=inherit --gtk-single-instance=false`. It changes directory using argv, launches one Ghostty per selected folder, and uses no embedded field-code expansion. No other embedded-code command is normalized; modifications fall back to the strict per-action refusal. Discovery and the pinned launch-plan read perform the same normalization, and confirmation displays the complete normalized argv. The original fixture is retained unchanged except for CRLF line endings. For `%f`/`%u`, the existing dialog is shown separately for each selected target; `%F`/`%U` shows one command for the selection. The desktop-file identity is captured around the bounded read at invocation and checked after confirmation and before every start; edits to the selected action since menu discovery require reopening the menu. Selected files are arguments, not executable identities; as with Open with, an explicitly confirmed handler can itself interpret their contents. @@ -54,3 +57,11 @@ Selected files are arguments, not executable identities; as with Open with, an e 7. `.desktop` files whose Exec expands field codes with file paths (`%f`) are shown with an empty file list; opening them via this path never passes files. 8. No "remember this launcher" (xattr trust): always prompts. 9. Other callers of Win32-era open paths (recent files widget, toolbar) are not yet ported and not covered here (W-SYS/R1). + +## Built-in root terminal + +The Linux **Root actions → Open in terminal as root** command opens the configured/default terminal using the same `TerminalResolver` and process-launch seam as **Open in terminal**. A selected folder is used directly, a selected file uses its parent, and a background invocation uses the current folder. Only existing absolute local directories are accepted. The terminal receives its working-directory option when supported and the child process starts in that directory; paths remain literal argv values, including quotes, spaces and shell-looking characters. + +The inner argv prefers `run0 --chdir=` when present (run0 was introduced in systemd 256), then `sudo -s`, then `pkexec --keep-cwd `. The pkexec shell is the executable absolute `$SHELL`, falling back to `/bin/sh`; no shell command string or `-c` is constructed. See the [systemd 256 run0 manual](https://github.com/systemd/systemd/blob/v256/man/run0.xml) and [pkexec manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html). These tools run inside the terminal; sudo authenticates there, while run0/pkexec may use the session's registered polkit agent (and may show a graphical authentication dialog). Files does not force or bypass the agent's policy. + +Like **Open in terminal**, the built-in terminal command has no launcher confirmation dialog. Selecting the explicitly labeled root item is the user's request for an interactive privileged shell, authenticated by the elevation tool. It intentionally grants a full root shell rather than the restricted operations of the file-operation helper. Terminal/PATH/SHELL configuration is trusted user configuration; files in the selected directory are never interpreted as launch instructions by Files. This command does not require the native file-operation helper. It shares the helper's disable gate: Flatpak/AppImage runtime detection, `FILES_DISABLE_ROOT_ACTIONS=1`, and the `.root-actions-disabled` marker hide it and refuse invocation. Existing delete/rename/paste gates and confirmations are unchanged. Tests record launches without spawning terminals or elevating; live authentication remains a manual check. diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs index b623e29121c1..5454aed2eae0 100644 --- a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs @@ -2,14 +2,15 @@ // Licensed under the MIT License. using Windows.Storage; +using System.IO; namespace Files.App.Data.Factories { public static partial class ContentPageContextFlyoutFactory { /// - /// LINUX-TODO(root-actions): "Open terminal here as root" and "Edit as root", see docs/linux-port/threat-model-elevation.md. - /// The Linux "Root actions" submenu (hidden on Windows and when pkexec is missing). Each entry asks polkit to authenticate. + /// The Linux "Root actions" submenu. File operations use the installed helper; terminals authenticate through their elevation tool. + /// LINUX-TODO(root-actions): "Edit as root", see docs/linux-port/threat-model-elevation.md. /// internal static ContextMenuFlyoutItemViewModel GetRootActionsItem(List selectedItems, bool itemsSelected, string? workingDirectory) { @@ -38,6 +39,19 @@ internal static ContextMenuFlyoutItemViewModel GetRootActionsItem(List RootActionsHelper.OpenTerminalAsync(terminalTarget)), + }); + } + // Paste goes into the open folder, or into the single selected folder var pasteTarget = itemsSelected ? (selectedItems.Count == 1 && selectedItems[0].PrimaryItemAttribute == StorageItemTypes.Folder ? selectedItems[0].ItemPath : null) diff --git a/src/Files.App/Helpers/RootActionsHelper.cs b/src/Files.App/Helpers/RootActionsHelper.cs index 487048233881..174ec259457b 100644 --- a/src/Files.App/Helpers/RootActionsHelper.cs +++ b/src/Files.App/Helpers/RootActionsHelper.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Files.Platform.Abstractions.Clipboard; +using Files.Platform.Abstractions.Launching; using Files.Platform.Abstractions.Elevation; using Files.Platform.Linux.Elevation; using Files.Platform.Linux.Launching; @@ -13,7 +14,7 @@ namespace Files.App.Helpers { /// /// Linux "Root actions" (like Dolphin's): delete, rename and paste as root through . - /// Every operation shows the exact command first and the system's polkit prompt authenticates it. + /// File operations show the exact command first and authenticate through polkit; interactive terminals use the launcher. /// internal static class RootActionsHelper { @@ -21,6 +22,12 @@ internal static class RootActionsHelper public static bool IsAvailable => Elevation?.IsAvailable ?? false; + public static bool CanOpenTerminal => OperatingSystem.IsLinux() && + (Ioc.Default.GetService()?.CanOpenTerminalAsRoot ?? false); + + public static Task OpenTerminalAsync(string folder) => + Ioc.Default.GetRequiredService().OpenTerminalAsRootAsync(folder); + private static bool dialogOpen; public static async Task DeleteAsync(IReadOnlyList paths) diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index a7707c0655b3..eebbccfa96b8 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -5051,4 +5051,7 @@ This service-menu action runs a separate command for each item. Select at most {0} items and try again. + + Open in terminal as root + diff --git a/src/Files.Platform.Abstractions/Launching/ILauncherService.cs b/src/Files.Platform.Abstractions/Launching/ILauncherService.cs index cb3e602b4fb0..0b87ea6002dc 100644 --- a/src/Files.Platform.Abstractions/Launching/ILauncherService.cs +++ b/src/Files.Platform.Abstractions/Launching/ILauncherService.cs @@ -40,6 +40,12 @@ public interface ILauncherService /// Task OpenTerminalAsync(string folderPath, CancellationToken cancellationToken = default); + /// Whether an interactive root terminal is available and permitted by the package. + bool CanOpenTerminalAsRoot => false; + + /// Opens a terminal in a folder running an interactive elevation command. + Task OpenTerminalAsRootAsync(string folderPath, CancellationToken cancellationToken = default) => Task.FromResult(false); + /// /// Starts an executable detached from this process. /// diff --git a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs index 92b6e0544b4b..db49121dfd70 100644 --- a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs +++ b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs @@ -165,9 +165,7 @@ public PkexecElevationService(ElevationPathChecker checker, ITrustedToolResolver this.checker = checker; this.tools = tools ?? new SystemToolResolver(checker); this.runner = runner; - this.packagedWithoutHelper = packagedWithoutHelper ?? (() => File.Exists("/.flatpak-info") || File.Exists(Path.Combine(AppContext.BaseDirectory, ".root-actions-disabled")) - || Environment.GetEnvironmentVariable("APPIMAGE") is not null || Environment.GetEnvironmentVariable("APPDIR") is not null - || Environment.GetEnvironmentVariable("FILES_DISABLE_ROOT_ACTIONS") == "1"); + this.packagedWithoutHelper = packagedWithoutHelper ?? (() => RootActionsAvailability.IsDisabled); } public bool IsAvailable => !packagedWithoutHelper() && tools.Resolve("pkexec") is not null && tools.Resolve("files-elevation-helper") == ElevationHelperProtocol.HelperPath; diff --git a/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs b/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs new file mode 100644 index 000000000000..6570444e2a02 --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs @@ -0,0 +1,16 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System; +using System.IO; + +namespace Files.Platform.Linux.Elevation +{ + public static class RootActionsAvailability + { + public static bool IsDisabled => File.Exists("/.flatpak-info") || + File.Exists(Path.Combine(AppContext.BaseDirectory, ".root-actions-disabled")) || + Environment.GetEnvironmentVariable("APPIMAGE") is not null || Environment.GetEnvironmentVariable("APPDIR") is not null || + Environment.GetEnvironmentVariable("FILES_DISABLE_ROOT_ACTIONS") == "1"; + } +} diff --git a/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs b/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs index 9c678dcf5d57..c8eb41fda663 100644 --- a/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs +++ b/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs @@ -22,16 +22,18 @@ public sealed class LinuxLauncherService : ILauncherService private readonly IApplicationRegistry applications; private readonly IProcessStarter starter; private readonly TerminalResolver terminals; + private readonly RootTerminalResolver rootTerminals; /// /// Creates the launcher. /// - public LinuxLauncherService(IMimeTypeService mimeTypes, IApplicationRegistry applications, IProcessStarter starter, TerminalResolver terminals) + public LinuxLauncherService(IMimeTypeService mimeTypes, IApplicationRegistry applications, IProcessStarter starter, TerminalResolver terminals, RootTerminalResolver? rootTerminals = null) { this.mimeTypes = mimeTypes; this.applications = applications; this.starter = starter; this.terminals = terminals; + this.rootTerminals = rootTerminals ?? new RootTerminalResolver(); } /// @@ -167,6 +169,19 @@ public Task OpenTerminalAsync(string folderPath, CancellationToken cancell return TryStartAsync(new ProcessLaunch(terminal.FileName, terminal.BuildOpenArguments(folderPath), folderPath), cancellationToken); } + public bool CanOpenTerminalAsRoot => terminals.Resolve() is not null && rootTerminals.Resolve("/") is not null; + + public Task OpenTerminalAsRootAsync(string folderPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(folderPath) || !folderPath.StartsWith('/') || folderPath.StartsWith("//", StringComparison.Ordinal) || + folderPath.Contains('\0') || !Directory.Exists(folderPath)) + return Task.FromResult(false); + var terminal = terminals.Resolve(); + var command = rootTerminals.Resolve(folderPath); + if (terminal is null || command is null) return Task.FromResult(false); + return TryStartAsync(new ProcessLaunch(terminal.FileName, terminal.BuildExecuteArguments(command, folderPath), folderPath), cancellationToken); + } + /// public Task RunExecutableAsync(string path, IReadOnlyList? arguments = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { diff --git a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs new file mode 100644 index 000000000000..bad71ee270b9 --- /dev/null +++ b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs @@ -0,0 +1,38 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Elevation; +using System; +using System.Collections.Generic; + +namespace Files.Platform.Linux.Launching +{ + /// Builds an interactive elevation command without interpreting file paths as code. + public sealed class RootTerminalResolver + { + private readonly IExecutableLocator locator; + private readonly Func environment; + private readonly Func disabled; + + public RootTerminalResolver() : this(new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => RootActionsAvailability.IsDisabled) { } + + public RootTerminalResolver(IExecutableLocator locator, Func environment, Func disabled) + { + this.locator = locator; + this.environment = environment; + this.disabled = disabled; + } + + public IReadOnlyList? Resolve(string folder) + { + if (disabled()) return null; + if (locator.Locate("run0") is { } run0) return [run0, "--chdir=" + folder]; + if (locator.Locate("sudo") is { } sudo) return [sudo, "-s"]; + if (locator.Locate("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 : [pkexec, "--keep-cwd", executable]; + } + } +} diff --git a/src/Files.Platform.Linux/Launching/TerminalResolver.cs b/src/Files.Platform.Linux/Launching/TerminalResolver.cs index dd4b27118db6..0950bc34163a 100644 --- a/src/Files.Platform.Linux/Launching/TerminalResolver.cs +++ b/src/Files.Platform.Linux/Launching/TerminalResolver.cs @@ -36,9 +36,9 @@ public List BuildOpenArguments(string folder) /// /// Builds the argument vector that runs inside the terminal. /// - public List BuildExecuteArguments(IReadOnlyList command) + public List BuildExecuteArguments(IReadOnlyList command, string? folder = null) { - var args = new List(LeadingArguments); + var args = folder is null ? new List(LeadingArguments) : BuildOpenArguments(folder); args.AddRange(ExecuteArguments); args.AddRange(command); return args; @@ -51,7 +51,7 @@ public List BuildExecuteArguments(IReadOnlyList command) public sealed class TerminalResolver { private static readonly string[] KnownTerminals = - ["konsole", "gnome-terminal", "kgx", "alacritty", "kitty", "wezterm", "foot", "xterm"]; + ["konsole", "gnome-terminal", "kgx", "alacritty", "kitty", "wezterm", "foot", "ghostty", "xterm"]; private readonly IExecutableLocator locator; private readonly Func getEnvironmentVariable; @@ -108,6 +108,7 @@ private static TerminalSpec Describe(string program, string[] extraArguments) "alacritty" => new(program, extraArguments, f => ["--working-directory", f], ["-e"]), "kitty" => new(program, extraArguments, f => ["--directory", f], []), "foot" => new(program, extraArguments, f => [$"--working-directory={f}"], []), + "ghostty" => new(program, extraArguments, f => [$"--working-directory={f}"], ["-e"]), "xdg-terminal-exec" => new(program, extraArguments, null, []), _ => new(program, extraArguments, null, ["-e"]), }; diff --git a/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs b/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs index 2c464fd62a4f..5a16d30c596b 100644 --- a/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs +++ b/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs @@ -107,7 +107,7 @@ public sealed record Entry(DesktopApplication Application, bool Hidden, string? } /// Validates every service-menu group, or just the application's [Desktop Entry] group. - internal static Dictionary>? ReadStrictGroups(IReadOnlyList lines, out string? error, bool desktopEntryOnly = false) + internal static Dictionary>? ReadStrictGroups(IReadOnlyList lines, out string? error, bool desktopEntryOnly = false, bool allowIdenticalDisplayKeys = false) { error = null; var groups = new Dictionary>(StringComparer.Ordinal); @@ -164,8 +164,13 @@ public sealed record Entry(DesktopApplication Application, bool Hidden, string? return null; } var key = line[..eq].TrimEnd(); + var value = Unescape(line[(eq + 1)..].TrimStart()); + if (allowIdenticalDisplayKeys && (key == "Name" || key.StartsWith("Name[", StringComparison.Ordinal) || + key == "X-KDE-Submenu" || key.StartsWith("X-KDE-Submenu[", StringComparison.Ordinal)) && + values.TryGetValue(key, out var existing) && existing == value) + continue; if (new[] { "Exec[", "Type[", "Terminal[", "Path[", "TryExec[" }.Any(prefix => key.StartsWith(prefix, StringComparison.Ordinal)) || - !values.TryAdd(key, Unescape(line[(eq + 1)..].TrimStart()))) + !values.TryAdd(key, value)) { error = "duplicate or localized execution key " + key; return null; diff --git a/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs b/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs index 83e8e46ece31..ff0024a133fa 100644 --- a/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs @@ -20,6 +20,7 @@ public sealed class LinuxServiceMenuService : IServiceMenuService public const int MaxScannedFiles = 512; public const int MaxActions = 256; public const int MaxSelection = 256; + public const int MaxKeys = 1000; private readonly XdgDirectories directories; private readonly IMimeTypeService mimeTypes; private readonly CultureInfo culture; @@ -103,7 +104,7 @@ private static IReadOnlyList Ordered(List try { var lines = DesktopEntryDisplay.ReadLinesBounded(path); - if (lines is null || lines.Count(l => l.Contains('=')) > DesktopEntryDisplay.MaxKeys) return null; + if (lines is null || lines.Count(l => l.Contains('=')) > MaxKeys) return null; var menu = ServiceMenuParser.ParseStrict(lines, path, culture); return identity.Value.StillMatches(path) ? menu : null; } diff --git a/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs b/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs index 66dda6f7c08e..812efc67fc69 100644 --- a/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs +++ b/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs @@ -2,10 +2,13 @@ // Licensed under the MIT License. using Files.Platform.Abstractions.Mime; +using Files.Platform.Linux.Launching; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.IO; +using System.Text; namespace Files.Platform.Linux.Mime { @@ -25,7 +28,7 @@ public bool Matches(IReadOnlyList targets, IReadOnlyList mimeTyp if (scheme.Length == 0 || (Protocols.Count > 0 && !Protocols.Contains(scheme, StringComparer.OrdinalIgnoreCase))) return false; var chain = hierarchy.GetChain(mimeTypes[i]); - if (!MimeTypes.Any(pattern => pattern == "all/all" || (pattern == "all/allfiles" && mimeTypes[i] != "inode/directory") || + if (!MimeTypes.Any(pattern => pattern == "all/all" || ((pattern == "all/allfiles" || pattern == "application/octet-stream") && mimeTypes[i] != "inode/directory") || chain.Contains(hierarchy.Canonicalize(pattern)) || (pattern.EndsWith("/*", StringComparison.Ordinal) && chain.Any(m => m.StartsWith(pattern[..^1], StringComparison.Ordinal))))) return false; @@ -38,14 +41,15 @@ public static class ServiceMenuParser { public static ServiceMenuEntry? ParseStrict(IReadOnlyList lines, string path, CultureInfo culture) { - var groups = DesktopEntryParser.ReadStrictGroups(lines, out _); + var groups = DesktopEntryParser.ReadStrictGroups(lines, out _, allowIdenticalDisplayKeys: true); if (groups is null || !groups.TryGetValue("Desktop Entry", out var root) || root.GetValueOrDefault("Type") != "Service") return null; var ids = List(root.GetValueOrDefault("Actions")); if (ids.Length == 0 || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length) return null; - var types = root.TryGetValue("MimeType", out var mime) ? List(mime) : - List(root.GetValueOrDefault("X-KDE-ServiceTypes") ?? root.GetValueOrDefault("ServiceTypes")); + var types = List(root.GetValueOrDefault("MimeType")) + .Concat(List(root.GetValueOrDefault("ServiceTypes"))) + .Concat(List(root.GetValueOrDefault("X-KDE-ServiceTypes"))).Distinct(StringComparer.Ordinal).ToArray(); types = types.Where(t => t != "KonqPopupMenu/Plugin").ToArray(); if (types.Length == 0 || !Number(root, "X-KDE-MinNumberOfUrls", 1, out var min) || !Number(root, "X-KDE-MaxNumberOfUrls", int.MaxValue, out var max) || max < min) @@ -68,16 +72,41 @@ public static class ServiceMenuParser var exec = values.GetValueOrDefault("Exec"); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(exec) || exec.Any(char.IsControl)) return null; + // Normalize only Ghostty's shipped directory action; generic embedded field codes remain forbidden. + if (Path.GetFileName(path) == "com.mitchellh.ghostty.desktop" && + exec == "ghostty --working-directory=%F --gtk-single-instance=false") + exec = "env --chdir %f ghostty --working-directory=inherit --gtk-single-instance=false"; var app = new DesktopApplication(id, name, exec, path, values.GetValueOrDefault("Icon") ?? root.GetValueOrDefault("Icon"), RunInTerminal: (values.GetValueOrDefault("Terminal") ?? root.GetValueOrDefault("Terminal")) == "true"); - actions.Add(new ServiceMenuAction(id, app, DesktopEntryParser.GetLocalized(root, "X-KDE-Submenu", culture), + if (HasUnsupportedConditions(values)) continue; + if (DesktopExecExpander.ExpandServiceMenu(app, ["/service-menu-target"]).Count == 0) + continue; + actions.Add(new ServiceMenuAction(id, app, StripMnemonic(DesktopEntryParser.GetLocalized(root, "X-KDE-Submenu", culture)), root.GetValueOrDefault("X-KDE-Priority") ?? string.Empty)); } return new ServiceMenuEntry(actions, types, List(root.GetValueOrDefault("X-KDE-Protocols") ?? root.GetValueOrDefault("X-KDE-Protocol")), - min, max, required, root.GetValueOrDefault("Hidden") == "true" || root.GetValueOrDefault("NoDisplay") == "true"); + min, max, required, root.GetValueOrDefault("Hidden") == "true" || root.GetValueOrDefault("NoDisplay") == "true" || + HasUnsupportedConditions(root)); } - private static string[] List(string? value) => value is null ? [] : DesktopEntryParser.SplitList(value.Replace(',', ';')); + private static bool HasUnsupportedConditions(Dictionary values) => + new[] { "X-KDE-AuthorizeAction", "X-KDE-ShowIfRunning", "X-KDE-ShowIfDBusCall" } + .Any(key => !string.IsNullOrWhiteSpace(values.GetValueOrDefault(key))); + + private static string? StripMnemonic(string? text) + { + if (text is null) return null; + var result = new StringBuilder(); + for (var i = 0; i < text.Length; i++) + { + if (text[i] != '&') result.Append(text[i]); + else if (i + 1 < text.Length && text[i + 1] == '&') { result.Append('&'); i++; } + } + return result.ToString(); + } + + private static string[] List(string? value) => value is null ? [] : + DesktopEntryParser.SplitList(value.Replace(',', ';')).Select(item => item.Trim()).Where(item => item.Length > 0).ToArray(); private static bool Number(Dictionary values, string key, int fallback, out int number) { diff --git a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj index b5a9099e7f63..edc66578e20d 100644 --- a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj +++ b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj @@ -55,4 +55,7 @@ + + + diff --git a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs index c75289842728..bc6bbc8bd20c 100644 --- a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs +++ b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs @@ -43,7 +43,8 @@ private static (LinuxLauncherService Service, RecordingStarter Starter) Create(X new LinuxMimeTypeService(fx.Directories, culture), new LinuxApplicationRegistry(fx.Directories, culture, locator), starter, - new TerminalResolver(locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null)); + new TerminalResolver(locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null), + new RootTerminalResolver(locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null, () => false)); return (service, starter); } @@ -138,6 +139,55 @@ public async Task Open_WithoutDefaultApp_FallsBackToXdgOpen() CollectionAssert.AreEqual(new[] { "/a.txt" }, starter.Launches.Single().Arguments.ToArray()); } + [TestMethod] + [DataRow("konsole")] + [DataRow("gnome-terminal")] + [DataRow("xdg-terminal-exec")] + [DataRow("xterm")] + [DataRow("ghostty")] + public async Task RootTerminal_PreservesLiteralDirectoryAndUsesPreferredTool(string terminal) + { + 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"); + 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()); + 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"); + 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"); + 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"); + Assert.IsTrue(await fallback.OpenTerminalAsRootAsync(fx.Home)); + Assert.AreEqual("/usr/bin//bin/sh", fallbackStarter.Launches.Single().Arguments.Last()); + Assert.IsFalse(await sudo.OpenTerminalAsRootAsync("relative")); + Assert.IsFalse(await sudo.OpenTerminalAsRootAsync(fx.Home + "/missing")); + Assert.IsFalse(await sudo.OpenTerminalAsRootAsync("file:///tmp")); + var missingTool = Create(fx, null, "konsole"); + Assert.IsFalse(missingTool.Service.CanOpenTerminalAsRoot); + Assert.IsFalse(await missingTool.Service.OpenTerminalAsRootAsync(fx.Home)); + Assert.AreEqual(0, missingTool.Starter.Launches.Count); + var missingTerminal = Create(fx, null, "sudo"); + Assert.IsFalse(missingTerminal.Service.CanOpenTerminalAsRoot); + Assert.IsFalse(await missingTerminal.Service.OpenTerminalAsRootAsync(fx.Home)); + Assert.AreEqual(0, missingTerminal.Starter.Launches.Count); + var disabled = new RootTerminalResolver(new FakeLocator("run0", "sudo", "pkexec"), _ => null, () => true); + Assert.IsNull(disabled.Resolve(fx.Home)); + } + [TestMethod] public async Task Open_EmptyInput_ReturnsFalse() { diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/10-rootactions-folders.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/10-rootactions-folders.desktop new file mode 100644 index 000000000000..388bae41e4a6 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/10-rootactions-folders.desktop @@ -0,0 +1,370 @@ +[Desktop Entry] +Type=Service +MimeType=inode/directory; +Actions=OpenInKonsole;OpenInFilemanager;OpenWithCustom;Copy;Rename;Compress;Delete;ChangeRoot;ChangeUser;ChangeCustom;ChangePerm; +Icon=kgpg +X-KDE-Priority=TopLevel +X-KDE-Submenu=&Root Actions +X-KDE-Submenu[ca]=Accions de &root +X-KDE-Submenu[cs]=Root akce +X-KDE-Submenu[de]=Administ&rator-Aktionen +X-KDE-Submenu[el]=Ενέργειες Υπερχρήστη +X-KDE-Submenu[es]=Opciones de root +X-KDE-Submenu[et]=Juurkasutaja toimingud +X-KDE-Submenu[fi]=P&ääkäyttäjän toiminnot +X-KDE-Submenu[fr]=Actions d'&administration +X-KDE-Submenu[gl]=Accións coma root +X-KDE-Submenu[hu]=Rendszergazdai műveletek +X-KDE-Submenu[it]=Azioni come Root +X-KDE-Submenu[lt]=&Root veiksmai +X-KDE-Submenu[nb]=Handlinger som &root +X-KDE-Submenu[nl]=&Root Acties +X-KDE-Submenu[nn]=Handlingar som &root +X-KDE-Submenu[pl]=Działania administratora +X-KDE-Submenu[pt]=Ac&ções Administrativas +X-KDE-Submenu[pt_BR]=A&ções Administrativas +X-KDE-Submenu[pt_PT]=Ac&ções Administrativas +X-KDE-Submenu[ru]=Действия root +X-KDE-Submenu[sl]=Administratorska &opravila +X-KDE-Submenu[sr]=Корене &акције +X-KDE-Submenu[sr@latin]=Korene &akcije +X-KDE-Submenu[sv]=Root åtgärder +X-KDE-Submenu[tr]=&Yetkili Eylemleri +#X-KDE-Submenu[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenInKonsole] +Exec=kf6-servicemenus-rootactions.pl root_konsole_here konsole %U +Icon=utilities-terminal +Name=Open Terminal Here +Name[ca]=Obre terminal aquí +Name[cs]=Otevřít zde terminál +Name[de]=In Konsole öffnen +Name[el]=Άνοιγμα Κονσόλας Εδώ +Name[es]=Abrir terminal aqui +Name[et]=Ava terminal siin +Name[fi]=Avaa pääte tähän +Name[fr]=Ouvrir un terminal ici +Name[gl]=Abrir unha consola aquí +Name[hu]=Konzol indítása itt +Name[it]=Apri qui la Konsole +Name[lt]=Atverti čia terminalą +Name[nb]=Åpne terminal her +Name[nl]=Openen in Konsole +Name[nn]=Opne terminal her +Name[pl]=Otwórz terminal tutaj +Name[pt]=Abrir o terminal aqui +Name[pt_BR]=Abrir terminal aqui +Name[pt_PT]=Abrir o terminal aqui +Name[ru]=Открыть терминал здесь +Name[sl]=Tu odpri terminal +Name[sr]=Отвори терминал овде +Name[sr@latin]=Otvori terminal ovde +Name[sv]=Öppna terminal här +Name[tr]=Uçbirimi Burada Aç +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenInFilemanager] +Exec=kf6-servicemenus-rootactions.pl open_with defaultfm %U +Icon=system-file-manager +Name=Open in File Manager +Name[ca]=Obre en el gestor de fitxers +Name[cs]=Otevřít v souborovém manažeru +Name[de]=Im Dateimanager öffnen +Name[el]=Άνοιγμα στον Διαχειριστή Αρχείων +Name[es]=Abrir en gestor de archivos +Name[et]=Ava failihalduris +Name[fi]=Avaa tiedostomanagerilla +Name[fr]=Ouvrir dans le gestionnaire de fichiers +Name[gl]=Abrir co xestor de ficheiros +Name[hu]=Megynyitás fájlkezelővel +Name[it]=Apri in File Manager +Name[lt]=Atverti failų tvarkytuve +Name[nb]=Åpne i filbehandler +Name[nl]=Openen in Bestandsbeheerder +Name[nn]=Opne i filhandsamar +Name[pl]=Otwórz w menedżerze plików +Name[pt]=Abrir no Gestor de Ficheiros +Name[pt_BR]=Abrir no Gerenciador de Arquivos +Name[pt_PT]=Abrir no Gestor de Ficheiros +Name[ru]=Открыть в файловом менеджере +Name[sl]=Odpri v upravitelju datotek +Name[sr]=Отвори у менаџеру фајлова +Name[sr@latin]=Otvori u menadžeru fajlova +Name[sv]=Öppna i filhanterare +Name[tr]=Dosya Yöneticisinde Aç +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenWithCustom] +Exec=kf6-servicemenus-rootactions.pl custom_open_with %U +Icon=system-run +Name=Open with ... +Name[ca]=Obre amb... +Name[cs]=Otevřít v... +Name[de]=Öffnen mit... +Name[el]=Άνοιγμα με ... +Name[es]=Abrir con... +Name[et]=Ava rakendusega... +Name[fi]=Avaa ohjelmalla ... +Name[fr]=Ouvrir avec... +Name[gl]=Abrir con... +Name[hu]=Megnyitás ezzel... +Name[it]=Apri con... +Name[lt]=Atverti su ... +Name[nb]=Åpne med ... +Name[nl]=Openen met ... +Name[nn]=Opne med +Name[pl]=Otwórz z... +Name[pt]=Abrir com ... +Name[pt_BR]=Abrir com ... +Name[pt_PT]=Abrir com ... +Name[ru]=Открыть с помощью... +Name[sl]=Odpri z ... +Name[sr]=Отвори помоћу... +Name[sr@latin]=Otvori pomoću... +Name[sv]=Öppna med +Name[tr]=Birlikte Aç... +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Copy] +Exec=kf6-servicemenus-rootactions.pl root_copy %U +Icon=edit-copy +Name=Copy +Name[ca]=Copia +Name[cs]=Kopírovat +Name[el]=Αντιγραφή +Name[et]=Kopeeri +Name[fi]=Kopioi +Name[fr]=Copier +Name[gl]=Copiar +Name[hu]=Másolás +Name[it]=Copia +Name[lt]=Kopijuoti +Name[nb]=Kopier +Name[nl]=Kopiëren +Name[nn]=Kopier +Name[pl]=Kopiuj +Name[pt]=Copiar +Name[pt_PT]=Copiar +Name[ru]=Копировать +Name[sl]=Kopiraj +Name[sr]=Копирај +Name[sr@latin]=Kopiraj +Name[sv]=Kopiera +Name[tr]=Kopyala +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Rename] +Exec=kf6-servicemenus-rootactions.pl root_rename krename %U +Icon=edit-rename +Name=Move/Rename +Name[ca]=Mou/Reanomena +Name[cs]=Přesunout/Přejmenovat +Name[de]=Umbenennen +Name[el]=Μετακίνηση/Μετονομασία +Name[es]=Renombrar +Name[et]=Liiguta/Nimeta ümber +Name[fi]=Siirrä/Nimeä uudelleen +Name[fr]=Renommer +Name[gl]=Cambiar o nome +Name[hu]=Átnevezés +Name[it]=Rinomina +Name[lt]=Perkelti/pervadinti +Name[nb]=Endre navn +Name[nl]=Hernoemen +Name[nn]=Endra namn +Name[pl]=Zmień nazwę +Name[pt]=Mudar o nome +Name[pt_BR]=Renomear +Name[pt_PT]=Mudar o nome +Name[ru]=Переместить/переименовать +Name[sl]=Premakni/preimenuj +Name[sr]=Премести/преименуј +Name[sr@latin]=Premesti/preimenuj +Name[sv]=Flytta/byt namn +Name[tr]=Taşı/Yeniden İsimlendir +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Compress] +Exec=kf6-servicemenus-rootactions.pl root_compress %U +Icon=utilities-file-archiver +Name=Compress +Name[ca]=Compressió +Name[cs]=Zkomprimovat +Name[de]=Komprimieren +Name[el]=Συμπίεση +Name[es]=Comprimir +Name[et]=Paki +Name[fi]=Tiivistä +Name[fr]=Compresser +Name[gl]=Comprimir +Name[hu]=Tömörítés +Name[it]=Comprimi +Name[lt]=Suspausti +Name[nb]=Komprimer +Name[nl]=Comprimeren +Name[nn]=Komprimer +Name[pl]=Kompresuj +Name[pt]=Comprimir +Name[pt_BR]=Compactar +Name[pt_PT]=Comprimir +Name[ru]=Упаковать +Name[sl]=Stisni +Name[sr]=Компресуј +Name[sr@latin]=Kompresuj +Name[sv]=Komprimera +Name[tr]=Sıkıştır +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Delete] +Exec=kf6-servicemenus-rootactions.pl root_delete %U +Icon=edit-delete +Name=Delete +Name[ca]=Esborra +Name[cs]=Smazat +Name[de]=Löschen +Name[el]=Διαγραφή +Name[es]=Borrar +Name[et]=Kustuta +Name[fi]=Poista +Name[fr]=Supprimer +Name[gl]=Borrar +Name[hu]=Törlés +Name[it]=Elimina +Name[lt]=Ištrinti +Name[nb]=Slett +Name[nl]=Verwijderen +Name[nn]=Slett +Name[pl]=Usuń +Name[pt]=Apagar +Name[pt_BR]=Apagar +Name[pt_PT]=Apagar +Name[ru]=Удалить +Name[sl]=Zbriši +Name[sr]=Обриши +Name[sr@latin]=Obriši +Name[sv]=Ta bort +Name[tr]=Sil +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeRoot] +Exec=kf6-servicemenus-rootactions.pl root_ownership %U +Icon=preferences-desktop-user-password +Name=Ownership to Root +Name[ca]=Propietari a root +Name[cs]=Nastavit vlastníka na Root +Name[de]=Administrator zum Eigentümer machen +Name[el]=Ιδιοκτησία σε Υπερχρήστη +Name[es]=Cambiar propietario a root +Name[et]=Omanikuõigus juurkasutajale +Name[fi]=Omistus pääkäyttäjälle +Name[fr]=Attribuer à root +Name[gl]=Facer dono ao root +Name[hu]=Tulajdonos beállítása mint rendszergazda +Name[it]=Cambia proprietario in Root +Name[lt]=Nuosavybės teisės Root naudotojui +Name[nb]=Sett eierskap til root +Name[nl]=Root eigenaar maken +Name[nn]=Set eigarskap til root +Name[pl]=Zmień własność na administratora +Name[pt]=Alterar dono para 'Root' +Name[pt_BR]=Posse para 'Root' +Name[pt_PT]=Alterar dono para 'Root' +Name[ru]=Установить владельцем root +Name[sl]=Za lastnika nastavi administratorja +Name[sr]=Постави власништво на кореног корисника +Name[sr@latin]=Postavi vlasništvo na korenog korisnika +Name[sv]=Ändra ägare till root +Name[tr]=Dosyayı yetkili kullanıcının yap +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeUser] +Exec=kf6-servicemenus-rootactions.pl user_ownership %U +Icon=preferences-desktop-user +Name=Ownership to Active User +Name[ca]=Propietari a usuari actiu +Name[cs]=Nastavit vlastníka na aktuálního uživatele +Name[de]=Aktuellen Benutzer zum Eigentümer machen +Name[el]=Ιδιοκτησία σε Ενεργό Χρήστη +Name[es]=Cambiar propietario a usuario actual +Name[et]=Omanikuõigused praegusele kasutajale +Name[fi]=Omistus nykyiselle käyttäjälle +Name[fr]=Attribuer à l'utilisateur +Name[gl]=Facer dono ao usuario actual +Name[hu]=Tulajdonos beállítása az aktív felhasználóra +Name[it]=Cambia proprietario in utente attivo +Name[lt]=Nuosavybės teisės dabartiniam naudotojui +Name[nb]=Sett eierskap til aktiv bruker +Name[nl]=Actieve gebruiker eigenaar maken +Name[nn]=Set eigarskap til aktiv brukar +Name[pl]=Zmień własność na bieżącego użytkownika +Name[pt]=Alterar dono para o utilizador actual +Name[pt_BR]=Posse para 'Usuário Ativo' +Name[pt_PT]=Alterar dono para o utilizador actual +Name[ru]=Установить владельцем активного пользователя +Name[sl]=Za lastnika nastavi trenutnega uporabnika +Name[sr]=Постави власништво на тренутног корисника +Name[sr@latin]=Postavi vlasništvo na trenutnog korisnika +Name[sv]=Ändra ägare till aktuell användare +Name[tr]=Dosyayı şu anda oturumu açık kullanıcının yap +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeCustom] +Exec=kf6-servicemenus-rootactions.pl custom_ownership %U +Icon=user-properties +Name=Ownership to ... +Name[ca]=Propietari a... +Name[cs]=Nastavit vlastníka na... +Name[de]=Eigentümer wechseln... +Name[el]=Ιδιοκτησία σε ... +Name[es]=Cambiar propietario a... +Name[et]=Omanikuõigused kasutajale... +Name[fi]=Omistus käyttäjälle ... +Name[fr]=Attribuer à... +Name[gl]=Facer dono a... +Name[hu]=Tulajdonos csere... +Name[it]=Cambia proprietario in... +Name[lt]=Nuosavybės teises ... +Name[nb]=Sett eierskap til ... +Name[nl]=Wijzig eigenaar in ... +Name[nn]=Set eigarskap til ... +Name[pl]=Zmień własność na... +Name[pt]=Alterar dono para ... +Name[pt_BR]=Posse para ... +Name[pt_PT]=Alterar dono para ... +Name[ru]=Установить владельцем... +Name[sl]=Nastavi lastnika... +Name[sr]=Постави власништво... +Name[sr@latin]=Postavi vlasništvo... +Name[sv]=Ändra ägare till... +Name[tr]=Dosyanın sahibini değiştir... +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangePerm] +Exec=kf6-servicemenus-rootactions.pl root_permissions %U +Icon=folder-locked +Name=Change Permissions +Name[ca]=Canvia els permisos +Name[cs]=Změnit práva +Name[de]=Berechtigungen bearbeiten +Name[el]=Αλλαγή Αδειών +Name[es]=Cambiar permisos +Name[et]=Muuda õigusi +Name[fi]=Muuta käyttöoikeuksia +Name[fr]=Modifier les droits d'accès +Name[gl]=Cambiar os permisos +Name[hu]=Jogok megváltoztatása +Name[it]=Cambia permessi +Name[lt]=Keisti leidimus +Name[nb]=Endre Rettigheter +Name[nl]=Toegangsrechten instellen +Name[nn]=Endra Rettigheiter +Name[pl]=Zmień uprawnienia +Name[pt]=Alterar permissões +Name[pt_PT]=Alterar permissões +Name[ru]=Изменить разрешения +Name[sl]=Spremeni dovoljenja +Name[sr]=Измени дозволе +Name[sr@latin]=Izmeni dozvole +Name[sv]=Ändra rättigheter +Name[tr]=Yetkileri değiştir +#Name[xx]=Your string 'xx' is the country abbreviation diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/11-rootactions-files.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/11-rootactions-files.desktop new file mode 100644 index 000000000000..984a156cfa1e --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/11-rootactions-files.desktop @@ -0,0 +1,401 @@ +[Desktop Entry] +Type=Service +MimeType=application/octet-stream; +Actions=EditAsText;OpenInKonsoleFile;OpenInFilemanagerFile;OpenWithCustom;Copy;Rename;Compress;Delete;ChangeRoot;ChangeUser;ChangeCustom;ChangePerm; +Icon=kgpg +X-KDE-Priority=TopLevel +X-KDE-Submenu=&Root Actions +X-KDE-Submenu[ca]=Accions de &root +X-KDE-Submenu[cs]=Root Akce +X-KDE-Submenu[de]=Administ&rator-Aktionen +X-KDE-Submenu[el]=Ενέργειες Υπερχρήστη +X-KDE-Submenu[es]=Opciones de root +X-KDE-Submenu[et]=Juurkasutaja toimingud +X-KDE-Submenu[fi]=P&ääkäyttäjän toiminnot +X-KDE-Submenu[fr]=Actions d'&administration +X-KDE-Submenu[gl]=Accións coma root +X-KDE-Submenu[hu]=Rendszergazdai műveletek +X-KDE-Submenu[it]=Azioni come Root +X-KDE-Submenu[lt]=&Root veiksmai +X-KDE-Submenu[nb]=Handlinger som &root +X-KDE-Submenu[nl]=&Root Acties +X-KDE-Submenu[nn]=Handlingar som &root +X-KDE-Submenu[pl]=Działania administratora +X-KDE-Submenu[pt]=Ac&ções Administrativas +X-KDE-Submenu[pt_BR]=A&ções Administrativas +X-KDE-Submenu[pt_PT]=Ac&ções Administrativas +X-KDE-Submenu[ru]=Действия root +X-KDE-Submenu[sl]=Administratorska &opravila +X-KDE-Submenu[sr]=Корене &акције +X-KDE-Submenu[sr@latin]=Korene &akcije +X-KDE-Submenu[sv]=Root åtgärder +X-KDE-Submenu[tr]=&Yetkili Eylemleri +#X-KDE-Submenu[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action EditAsText] +Exec=kf6-servicemenus-rootactions.pl open_with defaultte %U +Icon=accessories-text-editor +Name=Open as Text +Name[ca]=Obre com a text +Name[cs]=Otevřít jako Text +Name[de]=Als Textdatei bearbeiten +Name[el]=Άνοιγμα σαν Κείμενο +Name[es]=Abrir como texto +Name[et]=Ava tekstina +Name[fi]=Avaa tekstinä +Name[fr]=Ouvrir comme texte +Name[gl]=Abrir coma texto +Name[hu]=Megnyitás szövegként +Name[it]=Apri come testo +Name[lt]=Atverti kaip tekstą +Name[nb]=Rediger +Name[nl]=Openen als tekst +Name[nn]=Rediger +Name[pl]=Otwórz jako tekst +Name[pt]=Editar como Texto +Name[pt_BR]=Abrir como Texto +Name[pt_PT]=Editar como Texto +Name[ru]=Открыть как текст +Name[sl]=Odpri kot besedilo +Name[sr]=Отвори као текст +Name[sr@latin]=Otvori kao tekst +Name[sv]=Redigera +Name[tr]=Metin olarak aç +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenInKonsoleFile] +Exec=kf6-servicemenus-rootactions.pl root_konsole_here konsole $(dirname "%f") +Icon=utilities-terminal +Name=Open Terminal Here +Name[ca]=Obre terminal aquí +Name[cs]=Otevřít zde terminál +Name[de]=In Konsole öffnen +Name[el]=Άνοιγμα Κονσόλας Εδώ +Name[es]=Abrir terminal aqui +Name[et]=Ava terminal siin +Name[fi]=Avaa pääte tähän +Name[fr]=Ouvrir un terminal ici +Name[gl]=Abrir unha consola aquí +Name[hu]=Konzol indítása itt +Name[it]=Apri qui la Konsole +Name[lt]=Atverti čia terminalą +Name[nb]=Åpne terminal her +Name[nl]=Openen in Konsole +Name[nn]=Opne terminal her +Name[pl]=Otwórz terminal tutaj +Name[pt]=Abrir o terminal aqui +Name[pt_BR]=Abrir terminal aqui +Name[pt_PT]=Abrir o terminal aqui +Name[ru]=Открыть терминал здесь +Name[sl]=Tu odpri terminal +Name[sr]=Отвори терминал овде +Name[sr@latin]=Otvori terminal ovde +Name[sv]=Öppna terminal här +Name[tr]=Uçbirimi Burada Aç +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenInFilemanagerFile] +Exec=kf6-servicemenus-rootactions.pl open_with defaultfm $(dirname "%f") +Icon=system-file-manager +Name=Open File Manager Here +Name[ca]=Obre gestor de fitxers aquí +Name[cs]=Otevřít v souborovém manažeru +Name[de]=Im Dateimanager öffnen +Name[el]=Άνοιγμα Διαχειριστή Αρχείων Εδώ +Name[es]=Abrir en gestor de archivos +Name[et]=Ava failihaldur siin +Name[fi]=Avaa tiedostomanagerilla +Name[fr]=Ouvrir dans le gestionnaire de fichiers +Name[gl]=Abrir o xestor de ficheiros aquí +Name[hu]=Megynyitás fájlkezelővel +Name[it]=Apri in File Manager +Name[lt]=Atverti failų tvarkytuve +Name[nb]=Åpne i filbehandler +Name[nl]=Openen in Bestandsbeheerder +Name[nn]=Opne i filhandsamar +Name[pl]=Otwórz w menedżerze plików +Name[pt]=Abrir no Gestor de Ficheiros +Name[pt_BR]=Abrir no Gerenciador de Arquivos +Name[pt_PT]=Abrir no Gestor de Ficheiros +Name[ru]=Открыть в файловом менеджере +Name[sl]=Odpri v upravitelju datotek +Name[sr]=Отвори у менаџеру фајлова +Name[sr@latin]=Otvori u menadžeru fajlova +Name[sv]=Öppna i filhanterare +Name[tr]=Dosya Yöneticisinde Aç +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action OpenWithCustom] +Exec=kf6-servicemenus-rootactions.pl custom_open_with %U +Icon=system-run +Name=Open with ... +Name[ca]=Obre amb... +Name[cs]=Otevřít v... +Name[de]=Öffnen mit... +Name[el]=Άνοιγμα με ... +Name[es]=Abrir con... +Name[et]=Ava rakendusega... +Name[fi]=Avaa ohjelmalla ... +Name[fr]=Ouvrir avec... +Name[es]=Abrir con... +Name[hu]=Megnyitás ezzel... +Name[it]=Apri con... +Name[lt]=Atverti su ... +Name[nb]=Åpne med ... +Name[nl]=Openen met ... +Name[nn]=Opne med +Name[pl]=Otwórz z... +Name[pt]=Abrir com ... +Name[pt_BR]=Abrir com ... +Name[pt_PT]=Abrir com ... +Name[ru]=Открыть с помощью... +Name[sl]=Odpri z ... +Name[sr]=Отвори помоћу... +Name[sr@latin]=Otvori pomoću... +Name[sv]=Öppna med... +Name[tr]=Birlikte aç... +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Copy] +Exec=kf6-servicemenus-rootactions.pl root_copy %U +Icon=edit-copy +Name=Copy +Name[ca]=Copia +Name[cs]=Kopírovat +Name[el]=Αντιγραφή +Name[et]=Kopeeri +Name[fi]=Kopioi +Name[fr]=Copier +Name[gl]=Copiar +Name[hu]=Másolás +Name[it]=Copia +Name[lt]=Kopijuoti +Name[nb]=Kopier +Name[nl]=Kopiëren +Name[nn]=Kopier +Name[pl]=Kopiuj +Name[pt]=Copiar +Name[pt_PT]=Copiar +Name[ru]=Копировать +Name[sl]=Kopiraj +Name[sr]=Копирај +Name[sr@latin]=Kopiraj +Name[sv]=Kopiera +Name[tr]=Kopyala +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Rename] +Exec=kf6-servicemenus-rootactions.pl root_rename krename %U +Icon=edit-rename +Name=Move/Rename +Name[ca]=Mou/Reanomena +Name[cs]=Přesunout/Přejmenovat +Name[de]=Umbenennen +Name[el]=Μετακίνηση/Μετονομασία +Name[es]=Renombrar +Name[et]=Liiguta/Nimeta ümber +Name[fi]=Siirrä/Nimeä uudelleen +Name[fr]=Renommer +Name[gl]=Cambiar o nome +Name[hu]=Átnevezés +Name[it]=Rinomina +Name[lt]=Perkelti/pervadinti +Name[nb]=Endre navn +Name[nl]=Hernoemen +Name[nn]=Endra namn +Name[pl]=Zmień nazwę +Name[pt]=Mudar o nome +Name[pt_BR]=Renomear +Name[pt_PT]=Mudar o nome +Name[ru]=Переместить/переименовать +Name[sl]=Premakni/preimenuj +Name[sr]=Премести/преименуј +Name[sr@latin]=Premesti/preimenuj +Name[sv]=Flytta/byt namn +Name[tr]=Taşı/Yeniden İsimlendir +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Compress] +Exec=kf6-servicemenus-rootactions.pl root_compress %U +Icon=utilities-file-archiver +Name=Compress +Name[ca]=Compressió +Name[cs]=Zkomprimovat +Name[de]=Komprimieren +Name[el]=Συμπίεση +Name[es]=Comprimir +Name[et]=Paki +Name[fi]=Tiivistä +Name[fr]=Compresser +Name[gl]=Comprimir +Name[hu]=Tömörítés +Name[it]=Comprimi +Name[lt]=Suspausti +Name[nb]=Komprimer +Name[nl]=Comprimeren +Name[nn]=Komprimer +Name[pl]=Kompresuj +Name[pt]=Comprimir +Name[pt_BR]=Compactar +Name[pt_PT]=Comprimir +Name[ru]=Упаковать +Name[sl]=Stisni +Name[sr]=Компресуј +Name[sr@latin]=Kompresuj +Name[sv]=Komprimera +Name[tr]=Sıkıştır +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action Delete] +Exec=kf6-servicemenus-rootactions.pl root_delete %U +Icon=edit-delete +Name=Delete +Name[ca]=Esborra +Name[cs]=Smazat +Name[de]=Löschen +Name[el]=Διαγραφή +Name[es]=Borrar +Name[et]=Kustuta +Name[fi]=Poista +Name[fr]=Supprimer +Name[gl]=Borrar +Name[hu]=Törlés +Name[it]=Elimina +Name[lt]=Ištrinti +Name[nb]=Slett +Name[nl]=Verwijderen +Name[nn]=Slett +Name[pl]=Usuń +Name[pt]=Apagar +Name[pt_BR]=Apagar +Name[pt_PT]=Apagar +Name[ru]=Удалить +Name[sl]=Zbriši +Name[sr]=Обриши +Name[sr@latin]=Obriši +Name[sv]=Ta bort +Name[tr]=Sil +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeRoot] +Exec=kf6-servicemenus-rootactions.pl root_ownership %U +Icon=preferences-desktop-user-password +Name=Ownership to Root +Name[ca]=Propietari a root +Name[cs]=Nastavit vlatníka na Root +Name[de]=Administrator zum Eigentümer machen +Name[el]=Ιδιοκτησία σε Υπερχρήστη +Name[es]=Cambiar propietario a root +Name[et]=Omanikuõigused juurkasutajale +Name[fi]=Omistus pääkäyttäjälle +Name[fr]=Attribuer à root +Name[gl]=Facer dono ao root +Name[hu]=Tulajdonos beállítása mint rendszergazda +Name[it]=Cambia proprietario in Root +Name[lt]=Nuosavybės teises Root naudotojui +Name[nb]=Sett eierskap til root +Name[nl]=Root eigenaar maken +Name[nn]=Set eigarskap til root +Name[pl]=Zmień własność na administratora +Name[pt]=Alterar dono para 'Root' +Name[pt_BR]=Posse para 'Root' +Name[pt_PT]=Alterar dono para 'Root' +Name[ru]=Установить владельцем root +Name[sl]=Za lastnika nastavi administratorja +Name[sr]=Постави власништво на кореног корисника +Name[sr@latin]=Postavi vlasništvo na korenog korisnika +Name[sv]=Ändra ägare till root +Name[tr]=Dosyayı yetkili kullanıcının yap +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeUser] +Exec=kf6-servicemenus-rootactions.pl user_ownership %U +Icon=preferences-desktop-user +Name=Ownership to Active User +Name[ca]=Propietari a usuari actiu +Name[cs]=Nastavit vlastníka na aktuálního uživatele +Name[de]=Aktuellen Benutzer zum Eigentümer machen +Name[el]=Ιδιοκτησία σε Ενεργό Χρήστη +Name[es]=Cambiar propietario a usuario actual +Name[et]=Omanikuõigused praegusele kasutajale +Name[fi]=Omistus nykyiselle käyttäjälle +Name[fr]=Attribuer à l'utilisateur +Name[gl]=Facer dono ao usuario actual +Name[hu]=Tulajdonos beállítása az aktív felhasználóra +Name[it]=Cambia proprietario in utente attivo +Name[lt]=Nuosavybės teises dabartiniam naudotojui +Name[nb]=Sett eierskap til aktiv bruker +Name[nl]=Actieve gebruiker eigenaar maken +Name[nn]=Set eigarskap til aktiv brukar +Name[pl]=Zmień własność na bieżącego użytkownika +Name[pt]=Alterar dono para o utilizador actual +Name[pt_BR]=Posse para 'Usuário Ativo' +Name[pt_PT]=Alterar dono para o utilizador actual +Name[ru]=Установить владельцем активного пользователя +Name[sl]=Za lastnika nastavi trenutnega uporabnika +Name[sr]=Постави власништво на тренутног корисника +Name[sr@latin]=Postavi vlasništvo na trenutnog korisnika +Name[sv]=Ändra ägare till aktuell användare +Name[tr]=Dosyayı şu anda oturumu açık kullanıcının yap +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangeCustom] +Exec=kf6-servicemenus-rootactions.pl custom_ownership %U +Icon=user-properties +Name=Ownership to ... +Name[ca]=Propietari a... +Name[cs]=Nastavit vlastníka na... +Name[de]=Eigentümer wechseln... +Name[el]=Ιδιοκτησία σε ... +Name[es]=Cambiar propietario a... +Name[et]=Omanikuõigused kasutajale... +Name[fi]=Omistus käyttäjälle ... +Name[fr]=Attribuer à... +Name[gl]=Facer dono a... +Name[hu]=Tulajdonos csere... +Name[it]=Cambia proprietario in... +Name[lt]=Nuosavybės teises ... +Name[nb]=Sett eierskap til ... +Name[nl]=Wijzig eigenaar in ... +Name[nn]=Set eigarskap til ... +Name[pl]=Zmień własność na... +Name[pt]=Alterar dono para ... +Name[pt_BR]=Posse para ... +Name[pt_PT]=Alterar dono para ... +Name[ru]=Установить владельцем... +Name[sl]=Nastavi lastnika... +Name[sr]=Постави власништво... +Name[sr@latin]=Postavi vlasništvo... +Name[sv]=Ändra ägare till... +Name[tr]=Dosyanın sahibini değiştir... +#Name[xx]=Your string 'xx' is the country abbreviation + +[Desktop Action ChangePerm] +Exec=kf6-servicemenus-rootactions.pl root_permissions %U +Icon=folder-locked +Name=Change Permissions +Name[ca]=Canvia els permisos +Name[cs]=Změnit práva +Name[de]=Berechtigungen bearbeiten +Name[el]=Αλλαγή Αδειών +Name[es]=Cambiar permisos +Name[et]=Muuda õigusi +Name[fi]=Muuta käyttöoikeuksia +Name[fr]=Modifier les droits d'accès +Name[gl]=Cambiar os permisos +Name[hu]=Jogok megváltoztatása +Name[it]=Cambia permessi +Name[lt]=Pakeisti leidimus +Name[nb]=Endre Rettigheter +Name[nl]=Toegangsrechten instellen +Name[nn]=Endra Rettigheiter +Name[pl]=Zmień uprawnienia +Name[pt]=Alterar permissões +Name[pt_PT]=Alterar permissões +Name[ru]=Изменить разрешения +Name[sl]=Spremeni dovoljenja +Name[sr]=Измени дозволе +Name[sr@latin]=Izmeni dozvole +Name[sv]=Ändra rättigheter +Name[tr]=Yetkileri değiştir +#Name[xx]=Your string 'xx' is the country abbreviation diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md new file mode 100644 index 000000000000..7c156f14d451 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md @@ -0,0 +1,9 @@ +These fixtures were copied read-only from `/usr/share/kio/servicemenus` on the development machine. Their content is preserved, including distro duplicate keys, localized execution keys and shell commands; only line endings were converted to CRLF. + +- `10-rootactions-folders.desktop`, `11-rootactions-files.desktop`: kf6-servicemenus-rootactions +- `com.mitchellh.ghostty.desktop`: Ghostty +- `converseen_import.desktop`: Converseen +- `installfont.desktop`, `konsolerun.desktop`: KDE +- `mat2.desktop`: mat2 + +Tests cover translated multi-action menus, safe actions beside shell actions, legacy MIME lists, authorization constraints and the narrowly normalized Ghostty directory action. Discovery does not execute these commands. diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop new file mode 100644 index 000000000000..5e83513901a4 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin +MimeType=inode/directory +Actions=RunGhosttyDir + +[Desktop Action RunGhosttyDir] +Name=Open Ghostty Here +Icon=com.mitchellh.ghostty +Exec=ghostty --working-directory=%F --gtk-single-instance=false + diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/converseen_import.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/converseen_import.desktop new file mode 100644 index 000000000000..bc3467719f26 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/converseen_import.desktop @@ -0,0 +1,29 @@ +[Desktop Entry] +Actions=converseenImport; +MimeType=image/* +ServiceTypes=KonqPopupMenu/Plugin,image/* +name=Open with Converseen +TryExec=converseen +Type=Service +X-KDE-Submenu=Open with Converseen +X-KDE-Submenu[fr]=Ouvrir avec Converseen +X-KDE-Submenu[it]=Apri con Converseen +X-KDE-Submenu[ka]=გახსნა Converseen-ით +X-KDE-Submenu[ro]=Deschide cu Converseen +X-KDE-Submenu[ru]=Открыть с помощью Converseen +X-KDE-Submenu[da]=Åbn med Converseen +TryExec=converseen + +[Desktop Action converseenImport] +Name=Import +Name[fr]=Importer +Name[it]=Importa +Name[ka]=შემოტანა +Name[ro]=Importați +Name[ru]=Импортировать +Name[da]=Importér +Name[x-test]=xxImportaxx +Icon=converseen +Type=Application +Terminal=false +Exec=find %U>$HOME/.converseen_list.txt && converseen --list $HOME/.converseen_list.txt diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/installfont.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/installfont.desktop new file mode 100644 index 000000000000..a14959781f49 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/installfont.desktop @@ -0,0 +1,64 @@ +[Desktop Entry] +X-KDE-ServiceTypes=KonqPopupMenu/Plugin,application/x-font-ttf,application/x-font-type1,application/x-font-bdf,application/x-font-pcf,application/x-font-otf,application/x-font-afm,fonts/package,font/ttf,font/otf,font/collection +Actions=installFont; +Type=Service + +[Desktop Action installFont] +Name=Install... +Name[ar]=ثبّت... +Name[ast]=Instalar… +Name[az]=Quraşdırmaq... +Name[be]=Усталяваць... +Name[bg]=Инсталиране... +Name[ca]=Instal·la... +Name[ca@valencia]=Instal·la… +Name[cs]=Instalovat... +Name[da]=Installér... +Name[de]=Installieren … +Name[el]=Εγκατάσταση... +Name[en_GB]=Install... +Name[eo]=Instali... +Name[es]=Instalar… +Name[et]=Paigalda... +Name[eu]=Instalatzea... +Name[fi]=Asenna… +Name[fr]=Installer... +Name[gl]=Instalar… +Name[he]=התקנה… +Name[hi]=संस्थापित करें... +Name[hsb]=Instalować... +Name[hu]=Telepítés… +Name[ia]=Installa ... +Name[id]=Instal... +Name[is]=Setja upp... +Name[it]=Installa... +Name[ja]=インストール... +Name[ka]=დაყენება... +Name[ko]=설치... +Name[lt]=Įdiegti... +Name[lv]=Instalēt... +Name[ml]=ഇൻസ്റ്റാൾ... +Name[nb]=Installer … +Name[nl]=Installeren... +Name[nn]=Installer … +Name[pa]=ਇੰਸਟਾਲ ਕਰੋ... +Name[pl]=Wgraj... +Name[pt]=Instalar... +Name[pt_BR]=Instalar... +Name[ro]=Instalare... +Name[ru]=Установить... +Name[sa]=संस्थापयन्तु... +Name[sk]=Inštalovať... +Name[sl]=Namesti... +Name[sv]=Installera... +Name[ta]=நிறுவு... +Name[tg]=Насб кардан... +Name[th]=ติดตั้ง... +Name[tr]=Kur… +Name[ug]=ئورنات… +Name[uk]=Встановити… +Name[vi]=Cài đặt… +Name[zh_CN]=安装... +Name[zh_TW]=安裝 +Icon=preferences-desktop-font-installer +Exec=kfontinst %U diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/konsolerun.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/konsolerun.desktop new file mode 100644 index 000000000000..5c0257c672b3 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/konsolerun.desktop @@ -0,0 +1,126 @@ +[Desktop Entry] +Type=Service +X-KDE-ServiceTypes=KonqPopupMenu/Plugin +MimeType=application/x-executable; +Actions=runInKonsole; +X-KDE-AuthorizeAction=shell_access + +[Desktop Action runInKonsole] +TryExec=konsole +Exec=konsole --hold -e %f +Icon=utilities-terminal + +Name=Run In Konsole +Name[ar]=شغل في كونسول +Name[az]=Konsole-də başlatmaq +Name[be]=Запусціць у Konsole +Name[be@latin]=Zapuscić u Konsole +Name[bg]=Изпълняване в Konsole +Name[ca]=Executa en el Konsole +Name[ca@valencia]=Executa en Konsole +Name[cs]=Spustit v Konsoli +Name[da]=Kør i Konsole +Name[de]=In Konsole ausführen +Name[el]=Εκτέλεση στο Konsole +Name[en_GB]=Run In Konsole +Name[eo]=Ruli en Konzolo +Name[es]=Ejecutar en Konsole +Name[et]=Käivita Konsoolis +Name[eu]=Exekutatu Konsole-n +Name[fa]=اجرا در کنسول +Name[fi]=Suorita Konsolessa +Name[fr]=Lancer dans Konsole +Name[ga]=Rith i Konsole +Name[gl]=Executar en Konsole +Name[he]=הפעלה ב־Konsole +Name[hu]=Futtatás a Konsole programban +Name[ia]=Executa in Konsole +Name[id]=Jalankan Di Konsole +Name[is]=Keyra í Konsole +Name[it]=Esegui in Konsole +Name[ja]=Konsole で実行 +Name[ka]=Konsole-ში გაშვება +Name[ko]=Konsole에서 실행 +Name[lt]=Paleisti konsolėje +Name[lv]=Palaist ar „Konsole“ +Name[nb]=Kjør i Konsole +Name[nds]=Binnen Konsole lopen +Name[nl]=In Konsole uitvoeren +Name[nn]=Køyr i konsoll +Name[pa]=ਕਨਸੋਲ ਵਿੱਚ ਚਲਾਓ +Name[pl]=Uruchom w Konsoli +Name[pt]=Executar no Konsole +Name[pt_BR]=Executar no Konsole +Name[ro]=Rulează în Konsolă +Name[ru]=Запустить в Konsole +Name[sa]=Run In Console इति +Name[sk]=Spustiť v Konsole +Name[sl]=Zaženi v Konsole +Name[sr]=Изврши у Конзоли +Name[sr@ijekavian]=Изврши у Конзоли +Name[sr@ijekavianlatin]=Izvrši u Konsoli +Name[sr@latin]=Izvrši u Konsoli +Name[sv]=Kör i Terminal +Name[ta]=கான்சோலில் இயக்கு +Name[th]=เรียกใช้ใน Konsole +Name[tr]=Konsole’da Çalıştır +Name[ug]=Konsole دا ئىجرا قىل +Name[uk]=Запустити у Konsole +Name[zh_CN]=在 Konsole 中运行 +Name[zh_TW]=在 Konsole 中執行 + +Comment=Run within Konsole +Comment[ar]=شغل في كونسول +Comment[az]=Əmri Konsole-də icra etmək +Comment[be]=Запусціць з дапамогай Konsole +Comment[be@latin]=Zapuscić z dapamohaj Konsole +Comment[bg]=Изпълняване в Konsole +Comment[ca]=Executa al Konsole +Comment[ca@valencia]=Executa en Konsole +Comment[cs]=Spustit v Konsoli +Comment[da]=Kør i Konsole +Comment[de]=In Konsole ausführen +Comment[el]=Εκτέλεση από την Κονσόλα +Comment[en_GB]=Run within Konsole +Comment[eo]=Ruli ene de Konzolo +Comment[es]=Ejecutar en Konsole +Comment[et]=Konsoolis käivitamine +Comment[eu]=Exekutatu Konsole barruan +Comment[fa]=اجرا داخل کنسول +Comment[fi]=Suorita Konsolessa +Comment[fr]=Exécuter dans Konsole +Comment[ga]=Rith laistigh de Konsole +Comment[gl]=Executar dentro de Konsole. +Comment[he]=הפעלה בתוך Konsole +Comment[hu]=Futtatás a Konsole-ban +Comment[ia]=Executa intra Konsole +Comment[id]=Jalankan dalam Konsole +Comment[is]=Keyra innan í Konsole +Comment[it]=Esegui in Konsole +Comment[ja]=Konsole 内で実行 +Comment[ka]=Konsole-ის შიგნით გაშვება +Comment[ko]=Konsole에서 실행 +Comment[lv]=Palaist ar „Konsole“ +Comment[nb]=Kjør inne i Konsole +Comment[nl]=In Konsole uitvoeren +Comment[nn]=Køyr inni konsoll +Comment[pa]=ਕਨਸੋਲ ਵਿੱਚ ਚਲਾਓ +Comment[pl]=Uruchamia przy pomocy Konsoli +Comment[pt]=Executar no Konsole +Comment[pt_BR]=Executar no Konsole +Comment[ro]=Rulează în cadrul Konsolei +Comment[ru]=Выполнить команду в Konsole +Comment[sa]=Konsole इत्यस्य अन्तः चालयन्तु +Comment[sk]=Spustiť v Konsole +Comment[sl]=Zaženi znotraj Konsole +Comment[sr]=Изврши у Конзоли +Comment[sr@ijekavian]=Изврши у Конзоли +Comment[sr@ijekavianlatin]=Izvrši u Konsoli +Comment[sr@latin]=Izvrši u Konsoli +Comment[sv]=Kör i Terminal +Comment[ta]=கான்சோலுக்குள் இயக்கு +Comment[tr]=Konsol İçinde Çalıştır +Comment[ug]=Konsole دا ئىجرا قىل +Comment[uk]=Запустити у Konsole +Comment[zh_CN]=在 Konsole 中运行 +Comment[zh_TW]=在 Konsole 中執行 diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/mat2.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/mat2.desktop new file mode 100644 index 000000000000..19d50c6ea201 --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/mat2.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +X-KDE-ServiceTypes=KonqPopupMenu/Plugin +MimeType=application/pdf;application/vnd.oasis.opendocument.chart;application/vnd.oasis.opendocument.formula;application/vnd.oasis.opendocument.graphics;application/vnd.oasis.opendocument.image;application/vnd.oasis.opendocument.presentation;application/vnd.oasis.opendocument.spreadsheet;application/vnd.oasis.opendocument.text;application/vnd.openxmlformats-officedocument.presentationml.presentation;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/x-bittorrent;application/zip;audio/flac;audio/mpeg;audio/ogg;audio/x-flac;image/jpeg;image/png;image/tiff;image/x-ms-bmp;image/webp;image/avif;image/jxl;text/plain;video/mp4;video/x-msvideo; +Actions=cleanMetadata; +Type=Service + +[Desktop Action cleanMetadata] +Name=Clean metadata +Name[de]=Metadaten löschen +Name[es]=Limpiar metadatos +Icon=/usr/share/icons/hicolor/scalable/apps/mat2.svg +Exec=kdialog --yesno "$( mat2 -s %F )" --title "Clean Metadata?" && mat2 %U +Exec[de]=kdialog --yesno "$( mat2 -s %F )" --title "Metadaten löschen?" && mat2 %U diff --git a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs index 76f5596c47a1..eca95f372d2b 100644 --- a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs +++ b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs @@ -237,8 +237,7 @@ public void Plan_PinsCodeBeforeDialog_AndRefusesChangedOrUnsupportedActions() Assert.IsFalse(plan.Identity.StillMatches(path)); Assert.IsNull(ServiceMenuLaunchPlan.Create(action, ["/a.png"], CultureInfo.InvariantCulture)); File.WriteAllText(path, Text(exec: "sh -c '%F'")); - var shell = ServiceMenuParser.ParseStrict(File.ReadAllLines(path), path, CultureInfo.InvariantCulture)!.Actions[0]; - Assert.IsNull(ServiceMenuLaunchPlan.Create(shell, ["/a.png"], CultureInfo.InvariantCulture)); + Assert.AreEqual(0, ServiceMenuParser.ParseStrict(File.ReadAllLines(path), path, CultureInfo.InvariantCulture)!.Actions.Count); } [TestMethod] @@ -312,6 +311,97 @@ public void Plan_RefusesArgvThatCannotBeDisplayedInFull() Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(remote, ["smb://host/file.png"]).Count); } + private static string Fixture(string name) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mime", "Fixtures", "ServiceMenus", name + ".desktop")); + + [TestMethod] + public async Task Scan_DistroRootMenusKeepAllLiteralActionsAndPinTheirCommands() + { + using var fx = new XdgFixture(); + foreach (var name in new[] { "10-rootactions-folders", "11-rootactions-files", "com.mitchellh.ghostty", "converseen_import", "installfont", "konsolerun", "mat2" }) + fx.Write("data/kio/servicemenus/" + name + ".desktop", Fixture(name)); + var service = new LinuxServiceMenuService(fx.Directories, new LinuxMimeTypeService(fx.Directories, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + var folders = await service.GetActionsAsync([fx.Home]); + var rootFolders = folders.Where(a => a.Submenu == "Root Actions").ToArray(); + Assert.AreEqual(11, rootFolders.Length); + Assert.AreEqual(12, folders.Count); + Assert.AreEqual("RunGhosttyDir", folders.Last().ActionId); + Assert.IsTrue(rootFolders.All(a => a.Priority == "TopLevel")); + Assert.AreEqual("OpenInKonsole", rootFolders[0].ActionId); + var file = fx.Write("home/unrecognized.file", "data"); + var files = await service.GetActionsAsync([file]); + CollectionAssert.AreEqual(new[] { "EditAsText", "OpenWithCustom", "Copy", "Rename", "Compress", "Delete", "ChangeRoot", "ChangeUser", "ChangeCustom", "ChangePerm" }, files.Select(a => a.ActionId).ToArray()); + foreach (var action in rootFolders.Concat(files)) + Assert.IsNotNull(ServiceMenuLaunchPlan.Create(action, [file], CultureInfo.InvariantCulture), action.ActionId); + } + + [TestMethod] + public void Plan_DistroGhosttyUsesLiteralDirectoryWithoutEmbeddedExpansion() + { + using var fx = new XdgFixture(); + var text = Fixture("com.mitchellh.ghostty"); + var path = fx.Write("data/kio/servicemenus/com.mitchellh.ghostty.desktop", text); + var action = ServiceMenuParser.ParseStrict(text.Split('\n'), path, CultureInfo.InvariantCulture)!.Actions.Single(); + var directory = Path.Combine(fx.Home, "a 'quoted' $(touch bad); folder"); + Directory.CreateDirectory(directory); + var plan = ServiceMenuLaunchPlan.Create(action, [directory, fx.Home], CultureInfo.InvariantCulture); + Assert.IsNotNull(plan); + Assert.AreEqual(2, plan.Invocations.Count); + CollectionAssert.AreEqual(new[] { "env", "--chdir", directory, "ghostty", "--working-directory=inherit", "--gtk-single-instance=false" }, plan.Invocations[0].ToArray()); + Assert.IsTrue(plan.Identity.StillMatches(path)); + Assert.AreEqual(0, ServiceMenuParser.ParseStrict(text.Split('\n'), "/other.desktop", CultureInfo.InvariantCulture)!.Actions.Count); + var modified = text.Replace("--gtk-single-instance=false", "--gtk-single-instance=false --title=%f"); + Assert.AreEqual(0, ServiceMenuParser.ParseStrict(modified.Split('\n'), path, CultureInfo.InvariantCulture)!.Actions.Count); + } + + [TestMethod] + public void Parse_DistroLegacyMimeFiltersAndUnsupportedShellMenus() + { + using var fx = new XdgFixture(); + var hierarchy = new MimeHierarchy(fx.Directories); + var fonts = Parse(Fixture("installfont")); + Assert.IsNotNull(fonts); + Assert.IsTrue(fonts.Matches(["/a.ttf"], ["font/ttf"], hierarchy)); + Assert.IsFalse(fonts.Matches(["/a.png"], ["image/png"], hierarchy)); + Assert.IsNull(Parse(Fixture("mat2"))); + Assert.AreEqual(0, Parse(Fixture("mat2").Replace("Exec[de]=", "IgnoredExec[de]="))!.Actions.Count); + Assert.IsNull(Parse(Fixture("converseen_import"))); // Duplicate execution metadata stays ambiguous. + Assert.IsFalse(Parse(Fixture("konsolerun"))!.Matches(["/a"], ["application/x-executable"], hierarchy)); + } + + [TestMethod] + [DataRow("X-KDE-AuthorizeAction=shell_access\n")] + [DataRow("X-KDE-ShowIfRunning=application\n")] + [DataRow("X-KDE-ShowIfDBusCall=org.example / method\n")] + public void Filter_HidesConditionsThatCannotBeEvaluated(string extra) + { + using var fx = new XdgFixture(); + Assert.IsFalse(Parse(Text(extra))!.Matches(["/a.png"], ["image/png"], new MimeHierarchy(fx.Directories))); + } + + [TestMethod] + public void Parse_SkipsUnsupportedActionsWithoutDiscardingLiteralActions() + { + var text = Text("X-KDE-Submenu=&Root && Other\n", "tool --input=%f") + .Replace("Actions=run;", "Actions=run;safe;") + "[Desktop Action safe]\nName=Safe\nExec=tool %U\n"; + var menu = Parse(text)!; + Assert.AreEqual(1, menu.Actions.Count); + Assert.AreEqual("safe", menu.Actions[0].ActionId); + Assert.AreEqual("Root & Other", menu.Actions[0].Submenu); + Assert.IsNull(Parse(text.Replace("Name=Safe", "Name=Safe\nName=Different"))); + Assert.IsNull(Parse(text.Replace("Exec=tool %U", "Exec=tool %U\nExec=tool %U"))); + } + + [TestMethod] + public void Filter_CombinesMimeListsAndRequiresMaximumCount() + { + using var fx = new XdgFixture(); + var menu = Parse(Text("ServiceTypes=KonqPopupMenu/Plugin,font/ttf\nX-KDE-ServiceTypes=KonqPopupMenu/Plugin;inode/directory;\nX-KDE-MaxNumberOfUrls=1\n"))!; + var hierarchy = new MimeHierarchy(fx.Directories); + Assert.IsTrue(menu.Matches(["/font"], ["font/ttf"], hierarchy)); + Assert.IsTrue(menu.Matches(["file:///dir"], ["inode/directory"], hierarchy)); + Assert.IsFalse(menu.Matches(["/a.png", "/b.png"], ["image/png", "image/png"], hierarchy)); + } + private sealed class MenuLogger : ILogger { public List<(Exception? Exception, string Message)> Warnings { get; } = []; From 79789ffd209beddfad2d8bb98ddc49cc3d96c978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 11:53:15 +0300 Subject: [PATCH 2/3] Add explicit Linux root mode and isolate root settings Normal launches offer only 'Open in terminal as root'. 'files --root' (and the 'Open in Root Mode' desktop action) opens a separate root-mode window with the helper-based root actions and a visible indicator. Running the whole app as root hides elevation actions and uses root's own HOME/XDG directories. Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/packaging.md | 6 +- docs/linux-port/threat-model-elevation.md | 12 +++ docs/linux-port/threat-model-launching.md | 2 +- .../io.github.memergamer.LinuxFiles.desktop | 6 +- .../Helpers/Navigation/NavigationHelpers.cs | 6 +- src/Files.App/Helpers/RootActionsHelper.cs | 10 +-- src/Files.App/MainWindow.Linux.cs | 8 ++ src/Files.App/MainWindow.xaml.cs | 3 + src/Files.App/Platforms/Desktop/Program.cs | 17 +++- src/Files.App/Strings/en-US/Resources.resw | 6 ++ .../Elevation/ElevationTrust.cs | 2 + .../Elevation/PkexecElevationService.cs | 2 +- .../Elevation/RootActionMode.cs | 33 +++++++ .../Elevation/RootActionsAvailability.cs | 8 ++ .../Elevation/RootStartupEnvironment.cs | 63 ++++++++++++++ .../Launching/RootTerminalResolver.cs | 2 +- .../Native/ElevationNative.cs | 18 ++++ .../ElevationServiceTests.cs | 12 +++ .../SystemIntegration/RootActionModeTests.cs | 87 +++++++++++++++++++ 19 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 src/Files.Platform.Linux/Elevation/RootActionMode.cs create mode 100644 src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs create mode 100644 tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index adfe8cfbfcef..bc1b5a7ab990 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -174,8 +174,12 @@ Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nig ## Root actions -Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfiles/files-elevation-helper` and `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` under `/usr/share/polkit-1/actions/`. Install polkit 0.105 or newer with distributor security fixes to enable the menu. The helper is never setuid; the action uses `auth_admin` without retaining authorization. AOT publishing requires clang and zlib development files. +Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfiles/files-elevation-helper` and `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` under `/usr/share/polkit-1/actions/`. Install polkit 0.105 or newer with distributor security fixes to enable helper actions in root mode. The helper is never setuid; the action uses `auth_admin` without retaining authorization. AOT publishing requires clang and zlib development files. For local installation, publish normally and opt in with `scripts/linux/install-local.sh --install-root-helper`. This separately runs `sudo python3 scripts/linux/install-root-helper.py --from artifacts/linux-x64/elevation-helper --sha256 `; the app stays in the user prefix. This is equivalent to `sudo make install`: trust the installer checkout and build output. The policy is embedded; a private root-owned helper snapshot is hashed and checked before installation. To inspect the package layout without privileges, run `python3 scripts/linux/install-root-helper.py --from --sha256 --destdir `. Remove the system helper and policy as administrator when uninstalling; the user-local uninstaller intentionally cannot remove system files. AppImage and Flatpak omit the helper, install no policy, and disable Root actions. They cannot supply host elevation, even when the native helper is installed separately. See [the elevation threat model](threat-model-elevation.md) for protocol, failure semantics and limitations. + +Normal launches show only **Root actions → Open in terminal as root**, independently of helper installation; authentication happens through the terminal elevation tool. Launch `files --root` or choose **Open in Root Mode** from the desktop entry to additionally show Delete/Rename/Paste as root when the trusted helper is installed. Each action requests polkit authentication; the GUI remains unprivileged. Tabs and the title show **(Root mode)**. Each `--root` invocation opens its own process/window and bypasses normal single-instance forwarding, so an existing ordinary window never acquires root mode. + +For an actual uid-0 launch (`sudo files`), tabs/title show **(Running as root)**, ordinary operations already run as root, and all helper/root-terminal actions are hidden. The normal terminal command remains available. Before app initialization, HOME and XDG config/data/cache/state are reset to root's account home/defaults; inherited user runtime/session-bus, GVfs and TMPDIR overrides are cleared. Unsafe root-owned directory configuration aborts startup before settings writes. This also protects the invoking user's settings when sudo preserves HOME or XDG variables. AppImage/Flatpak retain their full elevation disable gate, including the root-terminal item, even with `--root`. diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index 66ee28589bdb..fb2276e4edba 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -22,6 +22,18 @@ The application compares the complete serialized request with the displayed comm The helper independently requires version 1, exactly the known JSON fields, unique property names, a known operation, 1–64 unique, non-overlapping sources, and normalized absolute paths with no NUL, empty components, dot components or root `/`. Delete/move/rename refuse `/` and top-level FHS sources `/usr /etc /boot /bin /lib /lib32 /lib64 /libx32 /sbin /var /home /root /proc /sys /dev /run /srv /opt /mnt /media /tmp` before opening paths. Copy/move refuse `/proc`, `/sys` and `/dev` targets and their descendants. Rename carries an absolute target in the same parent; copy/move cannot target the source or its descendants or introduce duplicate destination names. Input is strict UTF-8, at most 64 KiB, with bounded JSON depth. Unknown/missing fields, trailing data and unsupported versions fail closed. Results have one ordered entry per source; malformed, missing, duplicate or mismatched results can never become success in the app. +## Root mode and process identity + +A normal non-root launch exposes only **Root actions → Open in terminal as root**. Delete, Rename and Paste as root appear only after an explicit `files --root` launch (or the desktop entry's **Open in Root Mode** action), and only when the trusted native helper/policy are installed. Root mode keeps the GUI unprivileged; each helper action still previews its frozen plan and requests fresh polkit authentication. Every tab and the window title carry the localized **(Root mode)** suffix. + +Each `--root` launch bypasses single-instance forwarding and opens a separate process/window, including when a normal instance or another root-mode instance already exists. Ordinary launches continue forwarding to the normal instance. Mode is fixed for that process and is never transferred to an existing ordinary window; the root-mode process does not claim the normal instance name or FileManager1. `--root` is recognized only before `--` and never as the operand of `--select`. + +When `geteuid() == 0` (including `sudo files`), tabs/title instead show **(Running as root)**. Normal file operations already have root privileges; the helper actions and root-terminal item are hidden, and the elevation service itself refuses helper invocation for uid 0. The ordinary **Open in terminal** command remains available. This privileged GUI is outside the helper's restricted-operation boundary. + +Before Uno, fonts, settings or application services initialize, every uid-0 launch resolves root's home through `getpwuid_r(0)` and replaces inherited HOME and all four XDG config/data/cache/state directories with root's own defaults, regardless of SUDO_UID. Inherited user runtime/session-bus, GVfs and TMPDIR overrides are cleared. The home and existing XDG directory ancestors must be root-owned real directories without group/other write access; unsafe or unavailable roots abort startup with a stderr diagnostic before settings are written. Thus a sudo-preserved user HOME/XDG environment cannot select the invoking user's settings directories. Administrator-controlled contents inside root's own home remain trusted configuration. Automated tests check the environment plan without changing the real process environment or writing root's settings. + +AppImage, Flatpak and the existing explicit disable gates hide and refuse all elevation actions, including the root terminal, in every mode. `--root` can still show its mode indicator, but cannot enable packaged elevation. No sandbox exception is introduced. + ## Descriptor-relative execution After authorization, the helper opens `/` once and walks every ancestor with single-component descriptor-relative opens. It prefers openat2 with `RESOLVE_NO_SYMLINKS|RESOLVE_BENEATH`. Only ENOSYS permits fallback to the per-component `O_PATH|O_NOFOLLOW|O_DIRECTORY` walk. Other errors fail closed. Each opened ancestor must be root/caller-owned and not group/other-writable, except root-owned sticky directories. No path operation follows a user-controlled symlink. diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md index d137bc0ad628..08fd34ec5ab3 100644 --- a/docs/linux-port/threat-model-launching.md +++ b/docs/linux-port/threat-model-launching.md @@ -64,4 +64,4 @@ The Linux **Root actions → Open in terminal as root** command opens the config The inner argv prefers `run0 --chdir=` when present (run0 was introduced in systemd 256), then `sudo -s`, then `pkexec --keep-cwd `. The pkexec shell is the executable absolute `$SHELL`, falling back to `/bin/sh`; no shell command string or `-c` is constructed. See the [systemd 256 run0 manual](https://github.com/systemd/systemd/blob/v256/man/run0.xml) and [pkexec manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html). These tools run inside the terminal; sudo authenticates there, while run0/pkexec may use the session's registered polkit agent (and may show a graphical authentication dialog). Files does not force or bypass the agent's policy. -Like **Open in terminal**, the built-in terminal command has no launcher confirmation dialog. Selecting the explicitly labeled root item is the user's request for an interactive privileged shell, authenticated by the elevation tool. It intentionally grants a full root shell rather than the restricted operations of the file-operation helper. Terminal/PATH/SHELL configuration is trusted user configuration; files in the selected directory are never interpreted as launch instructions by Files. This command does not require the native file-operation helper. It shares the helper's disable gate: Flatpak/AppImage runtime detection, `FILES_DISABLE_ROOT_ACTIONS=1`, and the `.root-actions-disabled` marker hide it and refuse invocation. Existing delete/rename/paste gates and confirmations are unchanged. Tests record launches without spawning terminals or elevating; live authentication remains a manual check. +Like **Open in terminal**, the built-in terminal command has no launcher confirmation dialog. Selecting the explicitly labeled root item is the user's request for an interactive privileged shell, authenticated by the elevation tool. It intentionally grants a full root shell rather than the restricted operations of the file-operation helper. Terminal/PATH/SHELL configuration is trusted user configuration; files in the selected directory are never interpreted as launch instructions by Files. This command does not require the native file-operation helper. It shares the helper's disable gate: Flatpak/AppImage runtime detection, `FILES_DISABLE_ROOT_ACTIONS=1`, and the `.root-actions-disabled` marker hide it and refuse invocation. Normal non-root launches show only this root-terminal item. `files --root` additionally enables the helper items when installed; a uid-0 process hides the root-terminal item and uses the ordinary terminal command instead. See [root mode and process identity](threat-model-elevation.md#root-mode-and-process-identity) for routing, indicators and settings isolation. Tests record launches without spawning terminals or elevating; live authentication remains a manual check. diff --git a/packaging/linux/io.github.memergamer.LinuxFiles.desktop b/packaging/linux/io.github.memergamer.LinuxFiles.desktop index 2aa75e3c521b..c7535119484a 100644 --- a/packaging/linux/io.github.memergamer.LinuxFiles.desktop +++ b/packaging/linux/io.github.memergamer.LinuxFiles.desktop @@ -17,8 +17,12 @@ StartupWMClass=Files Categories=System;FileTools;FileManager;Utility; MimeType=inode/directory; Keywords=files;folders;directory;explorer;browse;manager; -Actions=new-window; +Actions=new-window;root-mode; [Desktop Action new-window] Name=New Window Exec=files --new-window + +[Desktop Action root-mode] +Name=Open in Root Mode +Exec=files --root diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs index a35e7c7f67dc..c33f36095950 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs @@ -188,6 +188,10 @@ internal static void RefreshTabPathHints() } } } +#if !WINDOWS + foreach (var tab in MainPageViewModel.AppInstances.Where(tab => !string.IsNullOrEmpty(tab.Header))) + tab.Header = MainWindow.FormatRootModeTitle(tab.Header!); +#endif } private static string[] AncestorHints(string? path) @@ -386,7 +390,7 @@ await SafetyExtensions.IgnoreExceptions(async () => { var title = $"{windowTitle} - {(OperatingSystem.IsLinux() ? Strings.LinuxAppDisplayName.GetLocalizedResource() : "Files")}"; #if !WINDOWS - title = Files.Platform.Linux.Windowing.X11WindowChrome.SanitizeTitle(title); + title = Files.Platform.Linux.Windowing.X11WindowChrome.SanitizeTitle(MainWindow.FormatRootModeTitle(title)); MainWindow.Instance.UpdateLinuxWindowTitle(title); #endif MainWindow.Instance.AppWindow.Title = title; diff --git a/src/Files.App/Helpers/RootActionsHelper.cs b/src/Files.App/Helpers/RootActionsHelper.cs index 174ec259457b..9b315245f8c4 100644 --- a/src/Files.App/Helpers/RootActionsHelper.cs +++ b/src/Files.App/Helpers/RootActionsHelper.cs @@ -20,9 +20,9 @@ internal static class RootActionsHelper { private static IElevationService? Elevation => OperatingSystem.IsLinux() ? Ioc.Default.GetService() : null; - public static bool IsAvailable => Elevation?.IsAvailable ?? false; + public static bool IsAvailable => OperatingSystem.IsLinux() && RootActionsAvailability.Mode.AllowHelper && (Elevation?.IsAvailable ?? false); - public static bool CanOpenTerminal => OperatingSystem.IsLinux() && + public static bool CanOpenTerminal => OperatingSystem.IsLinux() && RootActionsAvailability.Mode.AllowRootTerminal && (Ioc.Default.GetService()?.CanOpenTerminalAsRoot ?? false); public static Task OpenTerminalAsync(string folder) => @@ -32,19 +32,19 @@ public static Task OpenTerminalAsync(string folder) => public static async Task DeleteAsync(IReadOnlyList paths) { - if (Elevation is { } elevation) + if (IsAvailable && Elevation is { } elevation) await ConfirmAndRunAsync(elevation, new ElevationPlanPreview(_ => elevation.PlanDelete(paths)), null); } public static async Task RenameAsync(string path) { - if (Elevation is { } elevation) + if (IsAvailable && Elevation is { } elevation) await ConfirmAndRunAsync(elevation, new ElevationPlanPreview(name => elevation.PlanRename(path, name)), Path.GetFileName(path)); } public static async Task PasteAsync(string destinationFolder) { - if (Elevation is not { } elevation || Ioc.Default.GetService() is not { } clipboard) + if (!IsAvailable || Elevation is not { } elevation || Ioc.Default.GetService() is not { } clipboard) return; var files = await clipboard.GetFilesAsync(); diff --git a/src/Files.App/MainWindow.Linux.cs b/src/Files.App/MainWindow.Linux.cs index eb603b3953df..9b9a57d72914 100644 --- a/src/Files.App/MainWindow.Linux.cs +++ b/src/Files.App/MainWindow.Linux.cs @@ -4,6 +4,7 @@ #if !WINDOWS using Files.Platform.Abstractions; using Files.Platform.Linux.Windowing; +using Files.Platform.Linux.Elevation; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Uno.UI.NativeElementHosting; @@ -23,6 +24,13 @@ public sealed partial class MainWindow public bool IsLinuxWindowMaximized => _linuxChrome?.IsMaximized ?? false; public event EventHandler? LinuxChromeChanged; + internal static string FormatRootModeTitle(string title) => title + (RootActionsAvailability.Mode.Indicator switch + { + RootModeIndicator.RootMode => Strings.LinuxRootModeSuffix.GetLocalizedResource(), + RootModeIndicator.RunningAsRoot => Strings.LinuxRunningAsRootSuffix.GetLocalizedResource(), + _ => string.Empty, + }); + public void InitializeLinuxChrome() { if (!OperatingSystem.IsLinux()) diff --git a/src/Files.App/MainWindow.xaml.cs b/src/Files.App/MainWindow.xaml.cs index 4ceb7946b1d9..3cbfe89803e8 100644 --- a/src/Files.App/MainWindow.xaml.cs +++ b/src/Files.App/MainWindow.xaml.cs @@ -42,6 +42,9 @@ public MainWindow() : base(416, 316) ExtendsContentIntoTitleBar = UseClientSideDecorations; #endif Title = OperatingSystem.IsLinux() ? Strings.LinuxAppDisplayName.GetLocalizedResource() : "Files"; +#if !WINDOWS + Title = FormatRootModeTitle(Title); +#endif AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent; AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent; AppWindow.TitleBar.ButtonPressedBackgroundColor = Colors.Transparent; diff --git a/src/Files.App/Platforms/Desktop/Program.cs b/src/Files.App/Platforms/Desktop/Program.cs index 1e4cb3106c8d..30fa81f728ab 100644 --- a/src/Files.App/Platforms/Desktop/Program.cs +++ b/src/Files.App/Platforms/Desktop/Program.cs @@ -3,6 +3,7 @@ using Files.Platform.Abstractions.Instance; using Files.Platform.Linux.Instance; +using Files.Platform.Linux.Elevation; using System.Text; using Uno.UI.Hosting; @@ -37,6 +38,17 @@ internal sealed class Program [STAThread] public static int Main(string[] args) { + try + { + RootStartupEnvironment.Apply(); + } + catch (System.IO.IOException) + { + Console.Error.WriteLine("[Files] Refusing root startup: root's account and settings directories must be exclusively controlled by root."); + return 1; + } + RootActionsAvailability.Configure(args); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // Selawik weights (Regular/Semibold/Bold/Light) are matched by family name + weight through fontconfig @@ -47,9 +59,10 @@ public static int Main(string[] args) // Uno reads Xft.dpi itself; this covers sessions that only export GDK/Qt scale variables Files.Platform.Linux.Windowing.DisplayScaleResolver.ApplyToProcess(); - // Single instance: D-Bus name (socket fallback). A second launch forwards its arguments to the running instance and exits. + // Root-mode launches use a separate process so they never change or forward into an ordinary window. + // Ordinary second launches forward through D-Bus (socket fallback). // FILES_NO_SINGLE_INSTANCE=1 skips this (for running several instances side by side while developing). - var noSingleInstance = Environment.GetEnvironmentVariable("FILES_NO_SINGLE_INSTANCE") == "1"; + var noSingleInstance = !RootActionsAvailability.Mode.UseSingleInstance || Environment.GetEnvironmentVariable("FILES_NO_SINGLE_INSTANCE") == "1"; // Keep the flag from leaking into apps and terminals launched from this instance Environment.SetEnvironmentVariable("FILES_NO_SINGLE_INSTANCE", null); diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index eebbccfa96b8..2ca07bf589a2 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -5054,4 +5054,10 @@ Open in terminal as root + + (Root mode) + + + (Running as root) + diff --git a/src/Files.Platform.Linux/Elevation/ElevationTrust.cs b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs index 7a8b05ee6eab..171f3423e975 100644 --- a/src/Files.Platform.Linux/Elevation/ElevationTrust.cs +++ b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs @@ -40,6 +40,8 @@ public ElevationPathChecker(IFileOwnershipInspector inspector, uint userId) /// Gets the inspector used for all lookups. public IFileOwnershipInspector Inspector => inspector; + internal uint CurrentUserId => userId; + /// /// Resolves every symbolic link in , including the last component. Returns null for broken links or loops. /// diff --git a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs index db49121dfd70..8aec117b86e2 100644 --- a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs +++ b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs @@ -168,7 +168,7 @@ public PkexecElevationService(ElevationPathChecker checker, ITrustedToolResolver this.packagedWithoutHelper = packagedWithoutHelper ?? (() => RootActionsAvailability.IsDisabled); } - public bool IsAvailable => !packagedWithoutHelper() && tools.Resolve("pkexec") is not null && tools.Resolve("files-elevation-helper") == ElevationHelperProtocol.HelperPath; + public bool IsAvailable => checker.CurrentUserId != 0 && !packagedWithoutHelper() && tools.Resolve("pkexec") is not null && tools.Resolve("files-elevation-helper") == ElevationHelperProtocol.HelperPath; public ElevatedPlanResult PlanDelete(IReadOnlyList paths) => Plan(ElevatedOperation.Delete, paths, null); public ElevatedPlanResult PlanCopy(IReadOnlyList sources, string destinationFolder) => Plan(ElevatedOperation.Copy, sources, destinationFolder); diff --git a/src/Files.Platform.Linux/Elevation/RootActionMode.cs b/src/Files.Platform.Linux/Elevation/RootActionMode.cs new file mode 100644 index 000000000000..5a16b13a76f3 --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/RootActionMode.cs @@ -0,0 +1,33 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Files.Platform.Linux.Elevation +{ + public enum RootModeIndicator { None, RootMode, RunningAsRoot } + + /// Decides elevation UI and instance routing without accessing the process environment. + public sealed record RootActionMode(RootModeIndicator Indicator, bool AllowHelper, bool AllowRootTerminal, bool UseSingleInstance) + { + public static RootActionMode Decide(bool requested, uint effectiveUserId, bool disabled) + { + if (effectiveUserId == 0) + return new(RootModeIndicator.RunningAsRoot, false, false, false); + + return new(requested ? RootModeIndicator.RootMode : RootModeIndicator.None, + requested && !disabled, !disabled, !requested); + } + + public static bool IsRequested(IReadOnlyList arguments) + { + for (var i = 0; i < arguments.Count; i++) + { + if (arguments[i] == "--") break; + if (arguments[i] == "--select") { i++; continue; } + if (arguments[i] == "--root") return true; + } + return false; + } + } +} diff --git a/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs b/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs index 6570444e2a02..c6918ba20a5a 100644 --- a/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs +++ b/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs @@ -3,11 +3,19 @@ using System; using System.IO; +using System.Collections.Generic; +using Files.Platform.Linux.Native; namespace Files.Platform.Linux.Elevation { public static class RootActionsAvailability { + private static bool requested; + + public static void Configure(IReadOnlyList arguments) => requested = RootActionMode.IsRequested(arguments); + + public static RootActionMode Mode => RootActionMode.Decide(requested, ProcessIdentityNative.CurrentUserId, IsDisabled); + public static bool IsDisabled => File.Exists("/.flatpak-info") || File.Exists(Path.Combine(AppContext.BaseDirectory, ".root-actions-disabled")) || Environment.GetEnvironmentVariable("APPIMAGE") is not null || Environment.GetEnvironmentVariable("APPDIR") is not null || diff --git a/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs new file mode 100644 index 000000000000..610ce600a68a --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs @@ -0,0 +1,63 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Native; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Files.Platform.Linux.Elevation +{ + /// Redirects root's settings before Uno or application services can use inherited user directories. + public static class RootStartupEnvironment + { + public static void Apply() + { + if (ProcessIdentityNative.CurrentUserId != 0) return; + var home = ElevationNative.HomeDirectory(0); + var overrides = GetOverrides(home); + var inspector = new StatxFileOwnershipInspector(); + ValidateDirectory(home, inspector, allowMissing: false); + foreach (var variable in new[] { "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME" }) + ValidateDirectory(overrides[variable]!, inspector, allowMissing: true); + foreach (var entry in overrides) + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } + + public static IReadOnlyDictionary GetOverrides(string rootHome) + { + if (!Path.IsPathFullyQualified(rootHome) || rootHome == "/" || rootHome.Contains('\0')) + throw new IOException("Root account home is invalid."); + + return new Dictionary + { + ["HOME"] = rootHome, + ["XDG_CONFIG_HOME"] = Path.Combine(rootHome, ".config"), + ["XDG_DATA_HOME"] = Path.Combine(rootHome, ".local/share"), + ["XDG_CACHE_HOME"] = Path.Combine(rootHome, ".cache"), + ["XDG_STATE_HOME"] = Path.Combine(rootHome, ".local/state"), + ["XDG_RUNTIME_DIR"] = null, + ["DBUS_SESSION_BUS_ADDRESS"] = null, + ["FILES_GVFS_DIR"] = null, + ["TMPDIR"] = null, + }; + } + + public static void ValidateDirectory(string directory, IFileOwnershipInspector inspector, bool allowMissing) + { + var path = "/"; + foreach (var component in Path.GetFullPath(directory).Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + path = Path.Combine(path, component); + if (!inspector.TryGetInfo(path, out var info)) + { + if (allowMissing && !File.Exists(path) && !Directory.Exists(path)) return; + throw new IOException("Root settings directory cannot be inspected."); + } + if (!info.IsDirectory || info.IsSymbolicLink || info.OwnerUserId != 0 || + (info.Mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0) + throw new IOException("Root settings directory is not exclusively controlled by root."); + } + } + } +} diff --git a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs index bad71ee270b9..0d6d17f2a913 100644 --- a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs +++ b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs @@ -14,7 +14,7 @@ public sealed class RootTerminalResolver private readonly Func environment; private readonly Func disabled; - public RootTerminalResolver() : this(new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => RootActionsAvailability.IsDisabled) { } + public RootTerminalResolver() : this(new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal) { } public RootTerminalResolver(IExecutableLocator locator, Func environment, Func disabled) { diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index 0160edc13922..dae58fdab7f2 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -72,6 +72,24 @@ internal static uint PrimaryGroup(uint uid) throw new IOException("Caller account exceeds safety limits."); } + internal static string HomeDirectory(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.Home == 0) + throw new IOException("Root account home unavailable."); + return Marshal.PtrToStringUTF8(entry.Home) ?? throw new IOException("Root account home unavailable."); + } + } + throw new IOException("Root account exceeds safety limits."); + } + 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/SystemIntegration/ElevationServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs index 7fa8c3c96d59..3195a1568980 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs @@ -112,6 +112,18 @@ public void RootActionsRequireTrustedInstalledHelperPolicyAndNativePackage() Assert.IsFalse(Service(fs, runner).IsAvailable); } + [TestMethod] + public async Task RootProcessNeverInvokesHelper() + { + var runner = new Runner(); + var plan = Service(FakeFs.Standard(), runner).PlanDelete(["/home/u/a"]).Plan!; + var rootService = new PkexecElevationService(new ElevationPathChecker(FakeFs.Standard(), 0), null, runner, () => false); + Assert.IsFalse(rootService.IsAvailable); + Assert.IsNull(rootService.PlanDelete(["/home/u/a"]).Plan); + Assert.IsFalse((await rootService.RunAsync(plan)).Succeeded); + Assert.AreEqual(0, runner.Calls); + } + [TestMethod] public void RenamePreviewUsesAbsoluteTargetAndRejectsUnsafeNames() { diff --git a/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs b/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs new file mode 100644 index 000000000000..25b3ce7ab3ff --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Elevation; +using Files.Platform.Linux.Native; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Files.Platform.Tests.SystemIntegration +{ + [TestClass] + public sealed class RootActionModeTests + { + [TestMethod] + [DataRow(false, 1000u, false, RootModeIndicator.None, false, true, true)] + [DataRow(true, 1000u, false, RootModeIndicator.RootMode, true, true, false)] + [DataRow(false, 1000u, true, RootModeIndicator.None, false, false, true)] + [DataRow(true, 1000u, true, RootModeIndicator.RootMode, false, false, false)] + [DataRow(false, 0u, false, RootModeIndicator.RunningAsRoot, false, false, false)] + [DataRow(true, 0u, false, RootModeIndicator.RunningAsRoot, false, false, false)] + [DataRow(false, 0u, true, RootModeIndicator.RunningAsRoot, false, false, false)] + [DataRow(true, 0u, true, RootModeIndicator.RunningAsRoot, false, false, false)] + public void ModeControlsActionsIndicatorAndForwarding(bool requested, uint uid, bool disabled, + RootModeIndicator indicator, bool helper, bool terminal, bool singleInstance) + { + var mode = RootActionMode.Decide(requested, uid, disabled); + Assert.AreEqual(indicator, mode.Indicator); + Assert.AreEqual(helper, mode.AllowHelper); + Assert.AreEqual(terminal, mode.AllowRootTerminal); + Assert.AreEqual(singleInstance, mode.UseSingleInstance); + } + + [TestMethod] + public void RootFlagIsExactAndRespectsLiteralPathsAndSelectOperands() + { + Assert.IsFalse(RootActionMode.IsRequested([])); + Assert.IsTrue(RootActionMode.IsRequested(["--root", "/tmp"])); + Assert.IsTrue(RootActionMode.IsRequested(["--new-window", "--select", "/tmp/a", "--root"])); + Assert.IsFalse(RootActionMode.IsRequested(["--", "--root"])); + Assert.IsFalse(RootActionMode.IsRequested(["--select", "--root"])); + Assert.IsFalse(RootActionMode.IsRequested(["--select=--root", "--rooted", "--root=true", "/tmp/--root"])); + } + + [TestMethod] + public void RootEnvironmentAlwaysReplacesInheritedUserStorageAndSession() + { + var overrides = RootStartupEnvironment.GetOverrides("/root"); + Assert.AreEqual("/root", overrides["HOME"]); + Assert.AreEqual("/root/.config", overrides["XDG_CONFIG_HOME"]); + Assert.AreEqual("/root/.local/share", overrides["XDG_DATA_HOME"]); + Assert.AreEqual("/root/.cache", overrides["XDG_CACHE_HOME"]); + Assert.AreEqual("/root/.local/state", overrides["XDG_STATE_HOME"]); + foreach (var name in new[] { "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "FILES_GVFS_DIR", "TMPDIR" }) + Assert.IsNull(overrides[name]); + Assert.ThrowsExactly(() => RootStartupEnvironment.GetOverrides("relative")); + Assert.ThrowsExactly(() => RootStartupEnvironment.GetOverrides("/")); + } + + private sealed class Inspector : IFileOwnershipInspector + { + public Dictionary Entries { get; } = new() + { + ["/root"] = new(true, false, 0, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute), + }; + public bool TryGetInfo(string path, out FileEntryInfo info) => Entries.TryGetValue(path, out info!); + } + + [TestMethod] + public void RootSettingsRefuseForeignOwnershipSymlinksAndWritableDirectories() + { + var inspector = new Inspector(); + RootStartupEnvironment.ValidateDirectory("/root", inspector, allowMissing: false); + var safe = inspector.Entries["/root"]; + foreach (var entry in new[] { safe with { OwnerUserId = 1000 }, safe with { IsSymbolicLink = true }, + safe with { Mode = safe.Mode | UnixFileMode.GroupWrite }, safe with { IsDirectory = false } }) + { + inspector.Entries["/root"] = entry; + Assert.ThrowsExactly(() => RootStartupEnvironment.ValidateDirectory("/root/.config", inspector, allowMissing: true)); + } + inspector.Entries["/root"] = safe; + inspector.Entries["/root/.config"] = safe with { IsSymbolicLink = true }; + Assert.ThrowsExactly(() => RootStartupEnvironment.ValidateDirectory("/root/.config", inspector, allowMissing: true)); + } + } +} From 416e1d9d023811e8e3b18b0c01a6ef81d8e4b18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 12:14:54 +0300 Subject: [PATCH 3/3] Harden root-mode startup and service-menu launching Sanitize PATH, XDG_*_DIRS and TERMINAL at euid 0, resolve root terminal tools from trusted system dirs, replace the Ghostty special case with a generic single-target --option=%x rule, and keep root mode out of portable builds. Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Reviewed-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/packaging.md | 6 +- docs/linux-port/threat-model-elevation.md | 8 +- docs/linux-port/threat-model-launching.md | 10 +-- packaging/linux/appimage/build-appimage.sh | 4 +- .../io.github.memergamer.LinuxFiles.yml | 2 + ...entPageContextFlyoutFactory.RootActions.cs | 2 +- .../Helpers/Navigation/NavigationHelpers.cs | 4 + src/Files.App/Helpers/RootActionsHelper.cs | 13 ++- .../Platforms/Desktop/App.Desktop.cs | 3 +- .../Elevation/ElevationTrust.cs | 4 +- .../Elevation/RootActionMode.cs | 2 +- .../Elevation/RootStartupEnvironment.cs | 9 +++ .../Launching/DesktopExecExpander.cs | 28 ++++++- .../Launching/LinuxLauncherService.cs | 6 +- .../Launching/RootTerminalResolver.cs | 79 +++++++++++++++---- .../Mime/LinuxServiceMenuService.cs | 3 +- .../Mime/ServiceMenuParser.cs | 5 -- .../Launching/LauncherServiceTests.cs | 43 +++++++++- .../Mime/Fixtures/ServiceMenus/README.md | 20 +++-- .../com.mitchellh.ghostty.desktop | 1 - .../Mime/ServiceMenuTests.cs | 46 +++++++++-- .../ElevationServiceTests.cs | 31 ++++++++ .../SystemIntegration/RootActionModeTests.cs | 22 +++++- 23 files changed, 286 insertions(+), 65 deletions(-) diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index bc1b5a7ab990..47a5be3a539b 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -178,8 +178,8 @@ Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfil For local installation, publish normally and opt in with `scripts/linux/install-local.sh --install-root-helper`. This separately runs `sudo python3 scripts/linux/install-root-helper.py --from artifacts/linux-x64/elevation-helper --sha256 `; the app stays in the user prefix. This is equivalent to `sudo make install`: trust the installer checkout and build output. The policy is embedded; a private root-owned helper snapshot is hashed and checked before installation. To inspect the package layout without privileges, run `python3 scripts/linux/install-root-helper.py --from --sha256 --destdir `. Remove the system helper and policy as administrator when uninstalling; the user-local uninstaller intentionally cannot remove system files. -AppImage and Flatpak omit the helper, install no policy, and disable Root actions. They cannot supply host elevation, even when the native helper is installed separately. See [the elevation threat model](threat-model-elevation.md) for protocol, failure semantics and limitations. +AppImage and Flatpak omit the helper and the **Open in Root Mode** desktop action, install no policy, and disable Root actions. They cannot supply host elevation, even when the native helper is installed separately. See [the elevation threat model](threat-model-elevation.md) for protocol, failure semantics and limitations. -Normal launches show only **Root actions → Open in terminal as root**, independently of helper installation; authentication happens through the terminal elevation tool. Launch `files --root` or choose **Open in Root Mode** from the desktop entry to additionally show Delete/Rename/Paste as root when the trusted helper is installed. Each action requests polkit authentication; the GUI remains unprivileged. Tabs and the title show **(Root mode)**. Each `--root` invocation opens its own process/window and bypasses normal single-instance forwarding, so an existing ordinary window never acquires root mode. +Normal launches show only **Root actions → Open in terminal as root**, independently of helper installation; authentication happens through the terminal elevation tool. Launch `files --root` or choose **Open in Root Mode** from the desktop entry to additionally show Delete/Rename/Paste as root when the trusted helper is installed. Each action requests polkit authentication; the GUI remains unprivileged. Tabs and the title show **(Root mode)** only when helper actions are allowed by the package/disable gates. Each `--root` invocation opens its own process/window and bypasses normal single-instance forwarding, so an existing ordinary window never acquires root mode. **New Window** preserves `--root`. These separate processes never claim FileManager1. -For an actual uid-0 launch (`sudo files`), tabs/title show **(Running as root)**, ordinary operations already run as root, and all helper/root-terminal actions are hidden. The normal terminal command remains available. Before app initialization, HOME and XDG config/data/cache/state are reset to root's account home/defaults; inherited user runtime/session-bus, GVfs and TMPDIR overrides are cleared. Unsafe root-owned directory configuration aborts startup before settings writes. This also protects the invoking user's settings when sudo preserves HOME or XDG variables. AppImage/Flatpak retain their full elevation disable gate, including the root-terminal item, even with `--root`. +For an actual uid-0 launch (`sudo files`), tabs/title show **(Running as root)**, ordinary operations already run as root, and all helper/root-terminal actions are hidden. The normal terminal command remains available. Before app initialization, HOME and XDG config/data/cache/state are reset to root's account home/defaults; XDG_DATA_DIRS and XDG_CONFIG_DIRS are reset to `/usr/local/share:/usr/share` and `/etc/xdg`, and PATH to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`. Inherited TERMINAL, EDITOR, VISUAL, BROWSER, SHELL, XDG_CURRENT_DESKTOP, user runtime/session-bus, GVfs and TMPDIR overrides are cleared. Unsafe root-owned directory configuration aborts startup before settings writes. This also protects the invoking user's settings when sudo preserves HOME or XDG variables. AppImage/Flatpak retain their full elevation disable gate, including the root-terminal item, even with `--root`, and show no root-mode indicator when helper actions are disabled. diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index fb2276e4edba..1afcde575e5b 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -24,15 +24,15 @@ The helper independently requires version 1, exactly the known JSON fields, uniq ## Root mode and process identity -A normal non-root launch exposes only **Root actions → Open in terminal as root**. Delete, Rename and Paste as root appear only after an explicit `files --root` launch (or the desktop entry's **Open in Root Mode** action), and only when the trusted native helper/policy are installed. Root mode keeps the GUI unprivileged; each helper action still previews its frozen plan and requests fresh polkit authentication. Every tab and the window title carry the localized **(Root mode)** suffix. +A normal non-root launch exposes only **Root actions → Open in terminal as root**. Delete, Rename and Paste as root appear only after an explicit `files --root` launch (or the desktop entry's **Open in Root Mode** action), and only when the trusted native helper/policy are installed. Root mode keeps the GUI unprivileged; each helper action still previews its frozen plan and requests fresh polkit authentication. Every tab and the window title carry the localized **(Root mode)** suffix only when helper actions are allowed by the package/disable gates. -Each `--root` launch bypasses single-instance forwarding and opens a separate process/window, including when a normal instance or another root-mode instance already exists. Ordinary launches continue forwarding to the normal instance. Mode is fixed for that process and is never transferred to an existing ordinary window; the root-mode process does not claim the normal instance name or FileManager1. `--root` is recognized only before `--` and never as the operand of `--select`. +Each `--root` launch bypasses single-instance forwarding and opens a separate process/window, including when a normal instance or another root-mode instance already exists. Ordinary launches continue forwarding to the normal instance. **New Window** in a `--root` process passes `--root` to the new process. Mode is fixed for that process and is never transferred to an existing ordinary window; the root-mode process does not claim the normal instance name or FileManager1. `--root` is recognized only before `--` and never as the operand of `--select`. When `geteuid() == 0` (including `sudo files`), tabs/title instead show **(Running as root)**. Normal file operations already have root privileges; the helper actions and root-terminal item are hidden, and the elevation service itself refuses helper invocation for uid 0. The ordinary **Open in terminal** command remains available. This privileged GUI is outside the helper's restricted-operation boundary. -Before Uno, fonts, settings or application services initialize, every uid-0 launch resolves root's home through `getpwuid_r(0)` and replaces inherited HOME and all four XDG config/data/cache/state directories with root's own defaults, regardless of SUDO_UID. Inherited user runtime/session-bus, GVfs and TMPDIR overrides are cleared. The home and existing XDG directory ancestors must be root-owned real directories without group/other write access; unsafe or unavailable roots abort startup with a stderr diagnostic before settings are written. Thus a sudo-preserved user HOME/XDG environment cannot select the invoking user's settings directories. Administrator-controlled contents inside root's own home remain trusted configuration. Automated tests check the environment plan without changing the real process environment or writing root's settings. +Before Uno, fonts, settings or application services initialize, every uid-0 launch resolves root's home through `getpwuid_r(0)` and replaces inherited HOME and all four XDG config/data/cache/state directories with root's own defaults, regardless of SUDO_UID. `XDG_DATA_DIRS` and `XDG_CONFIG_DIRS` are reset to the specification defaults `/usr/local/share:/usr/share` and `/etc/xdg`; thumbnailers, desktop-entry trust and service menus therefore derive their directories from these sanitized values. `PATH` is reset to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`. Inherited TERMINAL, EDITOR, VISUAL, BROWSER, SHELL and XDG_CURRENT_DESKTOP are cleared, along with user runtime/session-bus, GVfs and TMPDIR overrides. The home and existing XDG directory ancestors must be root-owned real directories without group/other write access; unsafe or unavailable roots abort startup with a stderr diagnostic before settings are written. Thus a sudo-preserved user HOME/XDG environment cannot select the invoking user's settings directories. Administrator-controlled contents inside root's own home remain trusted configuration. Automated tests check the environment plan without changing the real process environment or writing root's settings. -AppImage, Flatpak and the existing explicit disable gates hide and refuse all elevation actions, including the root terminal, in every mode. `--root` can still show its mode indicator, but cannot enable packaged elevation. No sandbox exception is introduced. +AppImage, Flatpak and the existing explicit disable gates hide and refuse all elevation actions, including the root terminal, in every mode. Their desktop files omit **Open in Root Mode**, and `--root` shows no root-mode indicator when helper actions are disabled; it cannot enable packaged elevation. No sandbox exception is introduced. ## Descriptor-relative execution diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md index 08fd34ec5ab3..3464f2a7c0b5 100644 --- a/docs/linux-port/threat-model-launching.md +++ b/docs/linux-port/threat-model-launching.md @@ -21,7 +21,7 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar | 11 | Dry-run seam (`FILES_LAUNCH_DRYRUN`) so automated runs spawn nothing | `DryRunProcessStarter` | n/a | | 12 | Drop items onto an executable: same plan as gates 1-2 (only confirmable binaries/scripts); the dialog shows the full argv (target plus every dropped path, `DisplaySanitizer.FullArguments`, refused if too large) and exactly that argv is run after the identity re-check | `NavigationHelpers.RunWithItemsLinuxAsync` | `OnlyConfirmedActionsMayRunAFile`, `DisplaySanitizerTests` | | 13 | KDE service menus: every invocation (including system menus) uses `LaunchDesktopConfirm` and the existing launcher plan/dialog. User menus never gain trust from their directory or execute bit. Strict group/key validation is shared with the desktop parser; Type=Service and each declared action's Name/Exec are required; unsupported Exec actions are skipped individually | `ServiceMenuParser`, `RunServiceMenuLinuxAsync`, `ExecutePlanAsync` | `ServiceMenuTests` | -| 14 | Service Exec: tokenize first, then substitute `%f/%F/%u/%U` only as complete argv elements; no shell or embedded target-code expansion. Explicit shells, shell syntax, unknown codes and oversized argv are refused. Every argv is shown through `DisplaySanitizer.FullArguments`, kept unchanged and identity-checked before its start; cancelling stops the remaining invocations | `DesktopExecExpander.ExpandServiceMenu`, `ServiceMenuLaunchPlan` | `Expansion_*`, `Plan_PinsCodeBeforeDialog_*` | +| 14 | Service Exec: tokenize first, then substitute `%f/%F/%u/%U` as complete argv elements. For exactly one selected target, a literal `--long-option=%f/%F/%u/%U/%d/%D` argument may substitute its entire value into that one argv element; other embedded field codes are refused. No shell expansion. Explicit shells, shell syntax, unknown codes and oversized argv are refused. Every argv is shown through `DisplaySanitizer.FullArguments`, kept unchanged and identity-checked before its start; cancelling stops the remaining invocations | `DesktopExecExpander.ExpandServiceMenu`, `ServiceMenuLaunchPlan` | `Expansion_*`, `Plan_PinsCodeBeforeDialog_*` | | 15 | Service discovery: user entries override system entries by basename (hidden/invalid overrides included); every selected MIME/protocol and URL-count restriction must match. Scan at most 512 directory entries and return at most 256 actions for at most 256 selected targets; each file uses the existing pinned regular-file reader's 64 KiB/1000-line/4096-byte-line limits, with a service-specific 1000-key cap for translated multi-action menus (application launchers retain 200 keys). Final symlinks, devices and FIFOs are refused | `LinuxServiceMenuService`, `DesktopEntryDisplay.ReadLinesBounded` | `Scan_*`, `Filter_*` | Service menus are read from XDG data directories' `kio/servicemenus` and legacy `kservices5/ServiceMenus`, including `~/.local/share`. @@ -32,8 +32,8 @@ MIME filtering combines `MimeType`, `ServiceTypes` and `X-KDE-ServiceTypes`, dis Nonempty `X-KDE-AuthorizeAction`, `X-KDE-ShowIfRunning` and `X-KDE-ShowIfDBusCall` hide the affected menu or individual action: Files cannot evaluate KDE kiosk authorization, process identity or DBus predicates and never calls those predicates during discovery. This conservatively hides `konsolerun` rather than bypassing `shell_access` policy. See [KDE's service-menu format](https://develop.kde.org/docs/apps/dolphin/service-menus/). -Compatibility limits: unsupported actions are removed individually. Commands needing a shell, inline scripts containing target field codes, and extra KDE field codes such as `%D` are refused rather than interpreted. -The exact shipped `com.mitchellh.ghostty.desktop` Exec (`ghostty --working-directory=%F --gtk-single-instance=false`) is normalized to `env --chdir %f ghostty --working-directory=inherit --gtk-single-instance=false`. It changes directory using argv, launches one Ghostty per selected folder, and uses no embedded field-code expansion. No other embedded-code command is normalized; modifications fall back to the strict per-action refusal. Discovery and the pinned launch-plan read perform the same normalization, and confirmation displays the complete normalized argv. The original fixture is retained unchanged except for CRLF line endings. +Compatibility limits: unsupported actions are removed individually. Commands needing a shell, inline scripts containing target field codes, and standalone extra KDE field codes such as `%D` are refused rather than interpreted. +There is no application-name or basename special case. For exactly one selected target, an argument of the form `--long-option=%x` may use `%f`, `%F`, `%u`, `%U`, `%d` or `%D` as its entire value. The option name consists of ASCII letters/digits/hyphens, starting with a letter/digit. The literal option prefix and expanded value stay in one argv element: file codes use the local path, URI codes use the URI, and directory codes use the local target's parent directory. Prefix/suffix value text, short options, multiple target codes and all other embedded codes are refused. Embedded codes refuse zero or multiple selections, including `%F`/`%U`; discovery hides actions that cannot expand for the actual selection. The shipped Ghostty Exec stays unchanged and produces `ghostty`, `--working-directory=`, `--gtk-single-instance=false` for a single folder. The pinned launch plan computes and displays the complete expanded argv and passes it unchanged to execution. The fixture retains its content with CRLF line endings and a single final newline. For `%f`/`%u`, the existing dialog is shown separately for each selected target; `%F`/`%U` shows one command for the selection. The desktop-file identity is captured around the bounded read at invocation and checked after confirmation and before every start; edits to the selected action since menu discovery require reopening the menu. Selected files are arguments, not executable identities; as with Open with, an explicitly confirmed handler can itself interpret their contents. @@ -62,6 +62,6 @@ Selected files are arguments, not executable identities; as with Open with, an e The Linux **Root actions → Open in terminal as root** command opens the configured/default terminal using the same `TerminalResolver` and process-launch seam as **Open in terminal**. A selected folder is used directly, a selected file uses its parent, and a background invocation uses the current folder. Only existing absolute local directories are accepted. The terminal receives its working-directory option when supported and the child process starts in that directory; paths remain literal argv values, including quotes, spaces and shell-looking characters. -The inner argv prefers `run0 --chdir=` when present (run0 was introduced in systemd 256), then `sudo -s`, then `pkexec --keep-cwd `. The pkexec shell is the executable absolute `$SHELL`, falling back to `/bin/sh`; no shell command string or `-c` is constructed. See the [systemd 256 run0 manual](https://github.com/systemd/systemd/blob/v256/man/run0.xml) and [pkexec manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html). These tools run inside the terminal; sudo authenticates there, while run0/pkexec may use the session's registered polkit agent (and may show a graphical authentication dialog). Files does not force or bypass the agent's policy. +The inner argv prefers `run0 --chdir=` when present (run0 was introduced in systemd 256), then `sudo -s`, then `pkexec `. All three tools are resolved by the helper's `SystemToolResolver` from fixed `/usr/bin` and `/bin` locations with root ownership and no group/other write access along the resolved path chain, never from the user's PATH. A cached `pkexec --version` probe adds `--keep-cwd` only for polkit 0.121 or newer; older or unknown versions omit it (pkexec then starts in root's home). The pkexec shell is the executable absolute `$SHELL`, falling back to `/bin/sh`; no shell command string or `-c` is constructed. See the [systemd 256 run0 manual](https://github.com/systemd/systemd/blob/v256/man/run0.xml) and [pkexec manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html). These tools run inside the terminal; sudo authenticates there, while run0/pkexec may use the session's registered polkit agent (and may show a graphical authentication dialog). Files does not force or bypass the agent's policy. -Like **Open in terminal**, the built-in terminal command has no launcher confirmation dialog. Selecting the explicitly labeled root item is the user's request for an interactive privileged shell, authenticated by the elevation tool. It intentionally grants a full root shell rather than the restricted operations of the file-operation helper. Terminal/PATH/SHELL configuration is trusted user configuration; files in the selected directory are never interpreted as launch instructions by Files. This command does not require the native file-operation helper. It shares the helper's disable gate: Flatpak/AppImage runtime detection, `FILES_DISABLE_ROOT_ACTIONS=1`, and the `.root-actions-disabled` marker hide it and refuse invocation. Normal non-root launches show only this root-terminal item. `files --root` additionally enables the helper items when installed; a uid-0 process hides the root-terminal item and uses the ordinary terminal command instead. See [root mode and process identity](threat-model-elevation.md#root-mode-and-process-identity) for routing, indicators and settings isolation. Tests record launches without spawning terminals or elevating; live authentication remains a manual check. +Like **Open in terminal**, the built-in terminal command has no launcher confirmation dialog. Selecting the explicitly labeled root item is the user's request for an interactive privileged shell, authenticated by the elevation tool. It intentionally grants a full root shell rather than the restricted operations of the file-operation helper. Terminal/SHELL configuration is trusted user configuration for the unprivileged GUI; the elevation tool always comes from trusted system directories. Terminal/tool resolution is cached and warmed on a worker thread; menu construction performs no terminal target existence check. The click rechecks the directory and disable gate off the UI thread; files in the selected directory are never interpreted as launch instructions by Files. This command does not require the native file-operation helper. It shares the helper's disable gate: Flatpak/AppImage runtime detection, `FILES_DISABLE_ROOT_ACTIONS=1`, and the `.root-actions-disabled` marker hide it and refuse invocation. Normal non-root launches show only this root-terminal item. `files --root` additionally enables the helper items when installed; a uid-0 process hides the root-terminal item and uses the ordinary terminal command instead. See [root mode and process identity](threat-model-elevation.md#root-mode-and-process-identity) for routing, indicators and settings isolation. Tests record launches without spawning terminals or elevating; live authentication remains a manual check. diff --git a/packaging/linux/appimage/build-appimage.sh b/packaging/linux/appimage/build-appimage.sh index 4872a53907fd..2e4a9f00c7a6 100755 --- a/packaging/linux/appimage/build-appimage.sh +++ b/packaging/linux/appimage/build-appimage.sh @@ -60,11 +60,13 @@ rm -f "$appdir/usr/lib/linuxfiles/files-elevation-helper" touch "$appdir/usr/lib/linuxfiles/.root-actions-disabled" install -Dm755 "$root/packaging/linux/files" "$appdir/usr/bin/files" install -Dm644 "$root/packaging/linux/$app_id.desktop" "$appdir/usr/share/applications/$app_id.desktop" +# Portable images have no root mode; remove both its action and its menu registration. +sed -i 's/\r$//; /^\[Desktop Action root-mode\]/,$d; /^Actions=/s/root-mode;//' "$appdir/usr/share/applications/$app_id.desktop" install -Dm644 "$root/packaging/linux/$app_id.metainfo.xml" "$appdir/usr/share/metainfo/$app_id.metainfo.xml" cp -a "$root/packaging/linux/icons/hicolor" "$appdir/usr/share/icons" # AppImage root entries -cp "$root/packaging/linux/$app_id.desktop" "$appdir/$app_id.desktop" +cp "$appdir/usr/share/applications/$app_id.desktop" "$appdir/$app_id.desktop" cp "$root/packaging/linux/icons/hicolor/256x256/apps/$app_id.png" "$appdir/$app_id.png" ln -s "$app_id.png" "$appdir/.DirIcon" diff --git a/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml b/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml index 6fc9ac50f8e6..00d67ca73dc7 100644 --- a/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml +++ b/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml @@ -46,6 +46,8 @@ modules: - install -Dm755 packaging/linux/files /app/bin/files - install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.desktop /app/share/applications/io.github.memergamer.LinuxFiles.desktop + - sed -i 's/\r$//; /^\[Desktop Action root-mode\]/,$d; /^Actions=/s/root-mode;//' + /app/share/applications/io.github.memergamer.LinuxFiles.desktop - install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.metainfo.xml /app/share/metainfo/io.github.memergamer.LinuxFiles.metainfo.xml - for s in 16 24 32 48 64 128 256 512; do diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs index 5454aed2eae0..5058fb9648cd 100644 --- a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs @@ -43,7 +43,7 @@ internal static ContextMenuFlyoutItemViewModel GetRootActionsItem(List OperatingSystem.IsLinux() && RootActionsAvailability.Mode.AllowHelper && (Elevation?.IsAvailable ?? false); - public static bool CanOpenTerminal => OperatingSystem.IsLinux() && RootActionsAvailability.Mode.AllowRootTerminal && - (Ioc.Default.GetService()?.CanOpenTerminalAsRoot ?? false); + private static Task? terminalAvailability; - public static Task OpenTerminalAsync(string folder) => - Ioc.Default.GetRequiredService().OpenTerminalAsRootAsync(folder); + public static void InitializeTerminalAvailability() => terminalAvailability = Task.Run(() => + OperatingSystem.IsLinux() && RootActionsAvailability.Mode.AllowRootTerminal && + (Ioc.Default.GetService()?.CanOpenTerminalAsRoot ?? false)); + + public static bool CanOpenTerminal => terminalAvailability is { IsCompletedSuccessfully: true, Result: true }; + + public static Task OpenTerminalAsync(string folder) => Task.Run(() => + Ioc.Default.GetRequiredService().OpenTerminalAsRootAsync(folder)); private static bool dialogOpen; diff --git a/src/Files.App/Platforms/Desktop/App.Desktop.cs b/src/Files.App/Platforms/Desktop/App.Desktop.cs index 7b153b515f63..ab0fd8a6d080 100644 --- a/src/Files.App/Platforms/Desktop/App.Desktop.cs +++ b/src/Files.App/Platforms/Desktop/App.Desktop.cs @@ -32,6 +32,7 @@ async Task ActivateAsync() { var serviceProvider = AppLifecycleHelper.ConfigureHost(AppModel); Ioc.Default.ConfigureServices(serviceProvider); + RootActionsHelper.InitializeTerminalAvailability(); if (AppLifecycleHelper.AppEnvironment is not AppEnvironment.Dev) AppLifecycleHelper.ConfigureSentry(); @@ -88,7 +89,7 @@ private void StartInstanceRequestListener() }); }; - // FileManager1 (other applications' "Show in folder"): only claimed when the user opted in + // FileManager1 shares the single-instance guard above: root-mode and uid-0 processes never claim it. _ = Task.Run(async () => { try diff --git a/src/Files.Platform.Linux/Elevation/ElevationTrust.cs b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs index 171f3423e975..18e79e1e4da7 100644 --- a/src/Files.Platform.Linux/Elevation/ElevationTrust.cs +++ b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs @@ -15,7 +15,7 @@ namespace Files.Platform.Linux.Elevation public interface ITrustedToolResolver { /// - /// Returns the absolute path of (pkexec or files-elevation-helper), or null if it is not installed in a trusted location. + /// Returns the absolute path of (run0, sudo, pkexec or files-elevation-helper), or null if it is not installed in a trusted location. /// string? Resolve(string name); } @@ -134,7 +134,7 @@ public ElevationPathChecker(IFileOwnershipInspector inspector, uint userId) /// public sealed class SystemToolResolver : ITrustedToolResolver { - private static readonly HashSet Allowed = new(StringComparer.Ordinal) { "pkexec" }; + private static readonly HashSet Allowed = new(StringComparer.Ordinal) { "run0", "sudo", "pkexec" }; private static readonly string[] Directories = ["/usr/bin", "/bin"]; private readonly ElevationPathChecker checker; diff --git a/src/Files.Platform.Linux/Elevation/RootActionMode.cs b/src/Files.Platform.Linux/Elevation/RootActionMode.cs index 5a16b13a76f3..14e593b77544 100644 --- a/src/Files.Platform.Linux/Elevation/RootActionMode.cs +++ b/src/Files.Platform.Linux/Elevation/RootActionMode.cs @@ -15,7 +15,7 @@ public static RootActionMode Decide(bool requested, uint effectiveUserId, bool d if (effectiveUserId == 0) return new(RootModeIndicator.RunningAsRoot, false, false, false); - return new(requested ? RootModeIndicator.RootMode : RootModeIndicator.None, + return new(requested && !disabled ? RootModeIndicator.RootMode : RootModeIndicator.None, requested && !disabled, !disabled, !requested); } diff --git a/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs index 610ce600a68a..3f8ddd233ba4 100644 --- a/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs +++ b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs @@ -36,6 +36,15 @@ public static void Apply() ["XDG_DATA_HOME"] = Path.Combine(rootHome, ".local/share"), ["XDG_CACHE_HOME"] = Path.Combine(rootHome, ".cache"), ["XDG_STATE_HOME"] = Path.Combine(rootHome, ".local/state"), + ["XDG_DATA_DIRS"] = "/usr/local/share:/usr/share", + ["XDG_CONFIG_DIRS"] = "/etc/xdg", + ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ["TERMINAL"] = null, + ["EDITOR"] = null, + ["VISUAL"] = null, + ["BROWSER"] = null, + ["SHELL"] = null, + ["XDG_CURRENT_DESKTOP"] = null, ["XDG_RUNTIME_DIR"] = null, ["DBUS_SESSION_BUS_ADDRESS"] = null, ["FILES_GVFS_DIR"] = null, diff --git a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs index 76fa11ecdd12..5bdc356b6839 100644 --- a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs +++ b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs @@ -108,7 +108,7 @@ public static IReadOnlyList> Expand(DesktopApplication app return result; } - /// Service menus never invoke a shell. Target field codes must occupy complete argv elements. + /// Service menus never invoke a shell. Only a single target may fill a long option's entire value. public static IReadOnlyList> ExpandServiceMenu(DesktopApplication app, IReadOnlyList targets) { // Refuse shell syntax conservatively, even if quoted. Substituted paths are not inspected as command text. @@ -117,8 +117,10 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl if (tokens is null || tokens.Count == 0 || tokens[0].Contains('%') || tokens[0].Contains('=') || tokens.Any(t => Shells.Contains(System.IO.Path.GetFileName(t))) || (System.IO.Path.GetFileName(tokens[0]) == "env" && tokens.Any(t => t.StartsWith("-S", StringComparison.Ordinal) || t.StartsWith("--split-string", StringComparison.Ordinal)))) return []; - if (tokens.Count(t => t is "%f" or "%F" or "%u" or "%U") > 1 || - (tokens.Any(t => t is "%f" or "%F") && targets.Any(t => !ToPathOrUri(t).StartsWith('/')))) return []; + var targetCodes = tokens.Select(t => t is "%f" or "%F" or "%u" or "%U" ? t[1] : LongOptionTargetCode(t)).ToArray(); + if (targetCodes.Count(c => c != '\0') > 1 || + (targetCodes.Any(c => c is 'f' or 'F' or 'd' or 'D') && targets.Any(t => !ToPathOrUri(t).StartsWith('/'))) || + (tokens.Any(t => LongOptionTargetCode(t) != '\0') && targets.Count != 1)) return []; var single = tokens.Any(t => t is "%f" or "%u"); var result = new List>(); foreach (var target in single ? targets : new string[] { string.Empty }) @@ -138,6 +140,14 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl if (!string.IsNullOrEmpty(app.IconName)) { argv.Add("--icon"); argv.Add(app.IconName); } break; default: + var code = LongOptionTargetCode(token); + if (code != '\0') + { + var value = code is 'u' or 'U' ? ToUri(targets[0]) : PathArgument(targets[0]); + if (code is 'd' or 'D') value = System.IO.Path.GetDirectoryName(value) ?? "/"; + argv.Add(token[..^2] + value); + break; + } var literal = new StringBuilder(); for (var i = 0; i < token.Length; i++) { @@ -155,6 +165,18 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl return result; } + // Accept only --name=%x with an ASCII option name and no surrounding value text. + private static char LongOptionTargetCode(string token) + { + var equals = token.IndexOf('='); + if (!token.StartsWith("--", StringComparison.Ordinal) || equals < 3 || equals != token.Length - 3 || + token[equals + 1] != '%' || token[^1] is not ('f' or 'F' or 'u' or 'U' or 'd' or 'D') || + !char.IsAsciiLetterOrDigit(token[2])) return '\0'; + for (var i = 3; i < equals; i++) + if (!char.IsAsciiLetterOrDigit(token[i]) && token[i] != '-') return '\0'; + return token[^1]; + } + /// /// Quotes a value as a single shell word: wrapped in single quotes with embedded quotes escaped. /// diff --git a/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs b/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs index c8eb41fda663..f9274b4ea0ec 100644 --- a/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs +++ b/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs @@ -23,6 +23,7 @@ public sealed class LinuxLauncherService : ILauncherService private readonly IProcessStarter starter; private readonly TerminalResolver terminals; private readonly RootTerminalResolver rootTerminals; + private readonly Lazy rootTerminal; /// /// Creates the launcher. @@ -34,6 +35,7 @@ public LinuxLauncherService(IMimeTypeService mimeTypes, IApplicationRegistry app this.starter = starter; this.terminals = terminals; this.rootTerminals = rootTerminals ?? new RootTerminalResolver(); + rootTerminal = new Lazy(terminals.Resolve); } /// @@ -169,14 +171,14 @@ public Task OpenTerminalAsync(string folderPath, CancellationToken cancell return TryStartAsync(new ProcessLaunch(terminal.FileName, terminal.BuildOpenArguments(folderPath), folderPath), cancellationToken); } - public bool CanOpenTerminalAsRoot => terminals.Resolve() is not null && rootTerminals.Resolve("/") is not null; + public bool CanOpenTerminalAsRoot => rootTerminal.Value is not null && rootTerminals.Resolve("/") is not null; public Task OpenTerminalAsRootAsync(string folderPath, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(folderPath) || !folderPath.StartsWith('/') || folderPath.StartsWith("//", StringComparison.Ordinal) || folderPath.Contains('\0') || !Directory.Exists(folderPath)) return Task.FromResult(false); - var terminal = terminals.Resolve(); + var terminal = rootTerminal.Value; var command = rootTerminals.Resolve(folderPath); if (terminal is null || command is null) return Task.FromResult(false); return TryStartAsync(new ProcessLaunch(terminal.FileName, terminal.BuildExecuteArguments(command, folderPath), folderPath), cancellationToken); diff --git a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs index 0d6d17f2a913..3918132ded49 100644 --- a/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs +++ b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs @@ -2,37 +2,88 @@ // Licensed under the MIT License. using Files.Platform.Linux.Elevation; +using Files.Platform.Linux.Native; using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; namespace Files.Platform.Linux.Launching { /// Builds an interactive elevation command without interpreting file paths as code. public sealed class RootTerminalResolver { - private readonly IExecutableLocator locator; - private readonly Func environment; + private sealed record Configuration(string Tool, string? Shell = null, bool KeepWorkingDirectory = false); private readonly Func disabled; + private readonly Lazy configuration; - public RootTerminalResolver() : this(new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal) { } + public RootTerminalResolver() : this( + new SystemToolResolver(new ElevationPathChecker(new StatxFileOwnershipInspector(), ProcessIdentityNative.CurrentUserId)), + new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal) { } - public RootTerminalResolver(IExecutableLocator locator, Func environment, Func disabled) + public RootTerminalResolver(ITrustedToolResolver tools, IExecutableLocator locator, Func environment, + Func disabled, Func? version = null) { - this.locator = locator; - this.environment = environment; this.disabled = disabled; + configuration = new Lazy(() => + { + if (tools.Resolve("run0") is { } run0) return new(run0); + if (tools.Resolve("sudo") is { } sudo) return new(sudo); + 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))); + }); } public IReadOnlyList? Resolve(string folder) { - if (disabled()) return null; - if (locator.Locate("run0") is { } run0) return [run0, "--chdir=" + folder]; - if (locator.Locate("sudo") is { } sudo) return [sudo, "-s"]; - if (locator.Locate("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 : [pkexec, "--keep-cwd", executable]; + 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"]; + } + + public static bool SupportsKeepCwd(string? output) + { + const string prefix = "pkexec version "; + if (output is null || !output.Trim().StartsWith(prefix, StringComparison.Ordinal)) return false; + var number = output.Trim()[prefix.Length..]; + // Polkit switched from 0.xxx versions to integer versions after 0.122. + return int.TryParse(number, out var release) ? release >= 121 : + Version.TryParse(number, out var parsed) && parsed >= new Version(0, 121); + } + + private static string? ReadVersion(string pkexec) + { + try + { + var info = new ProcessStartInfo(pkexec) + { + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, + }; + info.Environment.Clear(); + info.Environment["LANG"] = "C"; + info.ArgumentList.Add("--version"); + using var process = Process.Start(info); + if (process is null) return null; + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(2000)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + return null; + } + error.GetAwaiter().GetResult(); + return process.ExitCode == 0 ? output.GetAwaiter().GetResult() : null; + } + catch (Exception ex) when (ex is Win32Exception or IOException or InvalidOperationException) + { + return null; + } } } } diff --git a/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs b/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs index ff0024a133fa..d9fda36c686b 100644 --- a/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxServiceMenuService.cs @@ -75,7 +75,8 @@ private IReadOnlyList Scan(IReadOnlyList targets, IRe { var menu = Read(path, culture, out _, logger); if (menu is null || !menu.Matches(targets, types, hierarchy)) continue; - result.AddRange(menu.Actions.Take(MaxActions - result.Count)); + result.AddRange(menu.Actions.Where(a => DesktopExecExpander.ExpandServiceMenu(a.Application, targets).Count > 0) + .Take(MaxActions - result.Count)); } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException or OperationCanceledException)) { diff --git a/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs b/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs index 812efc67fc69..34fe65459821 100644 --- a/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs +++ b/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.IO; using System.Text; namespace Files.Platform.Linux.Mime @@ -72,10 +71,6 @@ public static class ServiceMenuParser var exec = values.GetValueOrDefault("Exec"); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(exec) || exec.Any(char.IsControl)) return null; - // Normalize only Ghostty's shipped directory action; generic embedded field codes remain forbidden. - if (Path.GetFileName(path) == "com.mitchellh.ghostty.desktop" && - exec == "ghostty --working-directory=%F --gtk-single-instance=false") - exec = "env --chdir %f ghostty --working-directory=inherit --gtk-single-instance=false"; var app = new DesktopApplication(id, name, exec, path, values.GetValueOrDefault("Icon") ?? root.GetValueOrDefault("Icon"), RunInTerminal: (values.GetValueOrDefault("Terminal") ?? root.GetValueOrDefault("Terminal")) == "true"); if (HasUnsupportedConditions(values)) continue; diff --git a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs index bc6bbc8bd20c..59c18614784d 100644 --- a/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs +++ b/tests/Files.Platform.Tests/Launching/LauncherServiceTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Files.Platform.Linux.Launching; +using Files.Platform.Linux.Elevation; using Files.Platform.Linux.Mime; using Files.Platform.Tests.Mime; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -18,6 +19,44 @@ namespace Files.Platform.Tests.Launching [TestClass] public sealed class LauncherServiceTests { + private sealed class FakeTools(params string[] available) : ITrustedToolResolver + { + public string? Resolve(string name) => available.Contains(name) ? "/usr/bin/" + name : null; + } + + [TestMethod] + [DataRow("pkexec version 0.105", false)] + [DataRow("pkexec version 0.120", false)] + [DataRow("pkexec version 0.121", true)] + [DataRow("pkexec version 0.122\n", true)] + [DataRow("pkexec version 126", true)] + [DataRow(null, false)] + [DataRow("unknown", false)] + public void RootTerminal_PkexecKeepCwdRequiresSupportedVersionAndCachesResolution(string? output, bool supported) + { + var probes = 0; + var disabled = false; + var resolver = new RootTerminalResolver(new FakeTools("pkexec"), new FakeLocator("/bin/sh"), _ => null, + () => disabled, path => { Assert.AreEqual("/usr/bin/pkexec", path); probes++; return output; }); + Assert.AreEqual(supported, RootTerminalResolver.SupportsKeepCwd(output)); + var command = resolver.Resolve("/folder")!; + CollectionAssert.AreEqual(supported ? new[] { "/usr/bin/pkexec", "--keep-cwd", "/usr/bin//bin/sh" } : + new[] { "/usr/bin/pkexec", "/usr/bin//bin/sh" }, command.ToArray()); + CollectionAssert.AreEqual(command.ToArray(), resolver.Resolve("/another")!.ToArray()); + Assert.AreEqual(1, probes); + disabled = true; + Assert.IsNull(resolver.Resolve("/folder")); + } + + [TestMethod] + public void RootTerminal_NeverUsesUserPathToResolveElevationTools() + { + var userPath = new FakeLocator("run0", "sudo", "pkexec", "/bin/sh"); + var resolver = new RootTerminalResolver(new FakeTools(), userPath, _ => null, () => false, + _ => throw new AssertFailedException("An untrusted pkexec must never be probed.")); + Assert.IsNull(resolver.Resolve("/folder")); + } + private sealed class RecordingStarter : IProcessStarter { public List Launches { get; } = []; @@ -44,7 +83,7 @@ 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(locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null, () => false)); + new RootTerminalResolver(new FakeTools(executables), locator, name => env is not null && env.TryGetValue(name, out var v) ? v : null, () => false, _ => "pkexec version 0.121")); return (service, starter); } @@ -184,7 +223,7 @@ public async Task RootTerminal_FallbacksRefusalsAndPackageGate() Assert.IsFalse(missingTerminal.Service.CanOpenTerminalAsRoot); Assert.IsFalse(await missingTerminal.Service.OpenTerminalAsRootAsync(fx.Home)); Assert.AreEqual(0, missingTerminal.Starter.Launches.Count); - var disabled = new RootTerminalResolver(new FakeLocator("run0", "sudo", "pkexec"), _ => null, () => true); + var disabled = new RootTerminalResolver(new FakeTools("run0", "sudo", "pkexec"), new FakeLocator(), _ => null, () => true); Assert.IsNull(disabled.Resolve(fx.Home)); } diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md index 7c156f14d451..13ebbe681d9f 100644 --- a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md @@ -1,9 +1,15 @@ -These fixtures were copied read-only from `/usr/share/kio/servicemenus` on the development machine. Their content is preserved, including distro duplicate keys, localized execution keys and shell commands; only line endings were converted to CRLF. +These fixtures were copied read-only from `/usr/share/kio/servicemenus` on the development machine. Their content is preserved, including distro duplicate keys, localized execution keys and shell commands; line endings are CRLF and Ghostty's trailing blank line is removed. -- `10-rootactions-folders.desktop`, `11-rootactions-files.desktop`: kf6-servicemenus-rootactions -- `com.mitchellh.ghostty.desktop`: Ghostty -- `converseen_import.desktop`: Converseen -- `installfont.desktop`, `konsolerun.desktop`: KDE -- `mat2.desktop`: mat2 +Attribution below records the installed source package/version and its declared licence from the package database. These third-party fixtures retain their upstream licences. -Tests cover translated multi-action menus, safe actions beside shell actions, legacy MIME lists, authorization constraints and the narrowly normalized Ghostty directory action. Discovery does not execute these commands. +| Fixture | Source package | Licence | Upstream | +|---|---|---|---| +| `10-rootactions-folders.desktop` | kf6-servicemenus-rootactions 1.2.0-1 | GPL-2.0-or-later | https://gitlab.com/stefanwimmer128/kf6-servicemenus-rootactions | +| `11-rootactions-files.desktop` | kf6-servicemenus-rootactions 1.2.0-1 | GPL-2.0-or-later | https://gitlab.com/stefanwimmer128/kf6-servicemenus-rootactions | +| `com.mitchellh.ghostty.desktop` | ghostty 1.3.1-2 | MIT | https://github.com/ghostty-org/ghostty | +| `converseen_import.desktop` | converseen 0.15.2.8-1 | GPL-3.0-or-later | https://github.com/Faster3ck/Converseen | +| `installfont.desktop` | plasma-workspace 6.7.5-1 | LGPL-2.0-or-later | https://invent.kde.org/plasma/plasma-workspace | +| `konsolerun.desktop` | konsole 26.08.1-1 | GPL-2.0-or-later, LGPL-2.0-or-later | https://invent.kde.org/utilities/konsole | +| `mat2.desktop` | mat2 0.15.0-1 | LGPL-3.0-or-later | https://github.com/jvoisin/mat2 | + +Tests cover translated multi-action menus, safe actions beside shell actions, legacy MIME lists, authorization constraints and literal long-option value expansion, including Ghostty for a single folder. Discovery does not execute these commands. diff --git a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop index 5e83513901a4..7a0e8f93559e 100644 --- a/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop @@ -8,4 +8,3 @@ Actions=RunGhosttyDir Name=Open Ghostty Here Icon=com.mitchellh.ghostty Exec=ghostty --working-directory=%F --gtk-single-instance=false - diff --git a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs index eca95f372d2b..2bf84423f670 100644 --- a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs +++ b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs @@ -293,13 +293,41 @@ public void Expansion_TargetsRemainLiteralArgvElements(string field, bool urls, [DataRow("tool *.txt")] [DataRow("tool ~")] [DataRow("tool %f %U")] - [DataRow("tool --input=%f")] + [DataRow("tool --input=prefix%f")] + [DataRow("tool --input=%f/suffix")] + [DataRow("tool -i=%f")] + [DataRow("tool --=%f")] + [DataRow("tool --input=%f%f")] + [DataRow("tool --input=%c")] + [DataRow("tool --input=%f %U")] + [DataRow("tool --input=%f --other=%f")] [DataRow("tool '%F suffix'")] [DataRow("tool %D")] [DataRow("tool 'unterminated")] public void Expansion_RefusesShellsSyntaxAndEmbeddedTargetCodes(string exec) => Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), ["/a.png"]).Count); + [TestMethod] + [DataRow("f")] + [DataRow("F")] + [DataRow("u")] + [DataRow("U")] + [DataRow("d")] + [DataRow("D")] + public void Expansion_LongOptionValueUsesExactlyOneLiteralTarget(string code) + { + var target = "/tmp/a 'quoted' $(touch bad); directory/file.png"; + var app = new DesktopApplication("run", "Run", "tool --long-option=%" + code, "/any-name.desktop"); + var commands = DesktopExecExpander.ExpandServiceMenu(app, [target]); + var value = code is "u" or "U" ? DesktopExecExpander.ToUri(target) : code is "d" or "D" ? Path.GetDirectoryName(target) : target; + CollectionAssert.AreEqual(new[] { "tool", "--long-option=" + value }, commands.Single().ToArray()); + Assert.IsNotNull(DisplaySanitizer.FullArguments(commands[0])); + Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(app, []).Count); + Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(app, [target, "/another"]).Count); + if (code is "f" or "F" or "d" or "D") + Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(app, ["smb://host/file"]).Count); + } + [TestMethod] public void Plan_RefusesArgvThatCannotBeDisplayedInFull() { @@ -325,6 +353,8 @@ public async Task Scan_DistroRootMenusKeepAllLiteralActionsAndPinTheirCommands() Assert.AreEqual(11, rootFolders.Length); Assert.AreEqual(12, folders.Count); Assert.AreEqual("RunGhosttyDir", folders.Last().ActionId); + var multipleFolders = await service.GetActionsAsync([fx.Home, fx.Home]); + Assert.IsFalse(multipleFolders.Any(a => a.ActionId == "RunGhosttyDir")); Assert.IsTrue(rootFolders.All(a => a.Priority == "TopLevel")); Assert.AreEqual("OpenInKonsole", rootFolders[0].ActionId); var file = fx.Write("home/unrecognized.file", "data"); @@ -335,7 +365,7 @@ public async Task Scan_DistroRootMenusKeepAllLiteralActionsAndPinTheirCommands() } [TestMethod] - public void Plan_DistroGhosttyUsesLiteralDirectoryWithoutEmbeddedExpansion() + public void Plan_DistroGhosttyUsesLiteralLongOptionWithoutRewritingExec() { using var fx = new XdgFixture(); var text = Fixture("com.mitchellh.ghostty"); @@ -343,12 +373,14 @@ public void Plan_DistroGhosttyUsesLiteralDirectoryWithoutEmbeddedExpansion() var action = ServiceMenuParser.ParseStrict(text.Split('\n'), path, CultureInfo.InvariantCulture)!.Actions.Single(); var directory = Path.Combine(fx.Home, "a 'quoted' $(touch bad); folder"); Directory.CreateDirectory(directory); - var plan = ServiceMenuLaunchPlan.Create(action, [directory, fx.Home], CultureInfo.InvariantCulture); + var plan = ServiceMenuLaunchPlan.Create(action, [directory], CultureInfo.InvariantCulture); Assert.IsNotNull(plan); - Assert.AreEqual(2, plan.Invocations.Count); - CollectionAssert.AreEqual(new[] { "env", "--chdir", directory, "ghostty", "--working-directory=inherit", "--gtk-single-instance=false" }, plan.Invocations[0].ToArray()); + Assert.AreEqual(1, plan.Invocations.Count); + CollectionAssert.AreEqual(new[] { "ghostty", "--working-directory=" + directory, "--gtk-single-instance=false" }, plan.Invocations[0].ToArray()); + Assert.AreEqual("ghostty --working-directory=%F --gtk-single-instance=false", action.Application.Exec); Assert.IsTrue(plan.Identity.StillMatches(path)); - Assert.AreEqual(0, ServiceMenuParser.ParseStrict(text.Split('\n'), "/other.desktop", CultureInfo.InvariantCulture)!.Actions.Count); + Assert.AreEqual(1, ServiceMenuParser.ParseStrict(text.Split('\n'), "/other.desktop", CultureInfo.InvariantCulture)!.Actions.Count); + Assert.IsNull(ServiceMenuLaunchPlan.Create(action, [directory, fx.Home], CultureInfo.InvariantCulture)); var modified = text.Replace("--gtk-single-instance=false", "--gtk-single-instance=false --title=%f"); Assert.AreEqual(0, ServiceMenuParser.ParseStrict(modified.Split('\n'), path, CultureInfo.InvariantCulture)!.Actions.Count); } @@ -381,7 +413,7 @@ public void Filter_HidesConditionsThatCannotBeEvaluated(string extra) [TestMethod] public void Parse_SkipsUnsupportedActionsWithoutDiscardingLiteralActions() { - var text = Text("X-KDE-Submenu=&Root && Other\n", "tool --input=%f") + var text = Text("X-KDE-Submenu=&Root && Other\n", "tool --input=prefix%f") .Replace("Actions=run;", "Actions=run;safe;") + "[Desktop Action safe]\nName=Safe\nExec=tool %U\n"; var menu = Parse(text)!; Assert.AreEqual(1, menu.Actions.Count); diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs index 3195a1568980..ae825f1b5f5a 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs @@ -32,6 +32,37 @@ public static FakeFs Standard() } } + [TestMethod] + [DataRow("run0")] + [DataRow("sudo")] + [DataRow("pkexec")] + public void TerminalElevationToolsRequireRootOwnedNonWritableSystemChains(string name) + { + var fs = FakeFs.Standard(); + fs.File("/usr/bin/" + name); + fs.Dir("/home/u/bin", 1000); + fs.File("/home/u/bin/" + name, 1000); + var resolver = new SystemToolResolver(new ElevationPathChecker(fs, 1000)); + Assert.AreEqual("/usr/bin/" + name, resolver.Resolve(name)); + var tool = fs.Entries["/usr/bin/" + name]; + foreach (var unsafeTool in new[] { tool with { OwnerUserId = 1000 }, + tool with { Mode = tool.Mode | UnixFileMode.GroupWrite }, tool with { Mode = tool.Mode | UnixFileMode.OtherWrite } }) + { + fs.Entries["/usr/bin/" + name] = unsafeTool; + Assert.IsNull(resolver.Resolve(name)); + } + fs.Entries["/usr/bin/" + name] = tool; + foreach (var ancestor in new[] { "/", "/usr", "/usr/bin" }) + { + var safe = fs.Entries[ancestor]; + fs.Entries[ancestor] = safe with { Mode = safe.Mode | UnixFileMode.OtherWrite }; + Assert.IsNull(resolver.Resolve(name)); + fs.Entries[ancestor] = safe; + } + fs.Entries.Remove("/usr/bin/" + name); + Assert.IsNull(resolver.Resolve(name)); + } + private sealed class Runner : IRootHelperProcessRunner { public int Calls; diff --git a/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs b/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs index 25b3ce7ab3ff..9f2f0da19898 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs @@ -17,7 +17,7 @@ public sealed class RootActionModeTests [DataRow(false, 1000u, false, RootModeIndicator.None, false, true, true)] [DataRow(true, 1000u, false, RootModeIndicator.RootMode, true, true, false)] [DataRow(false, 1000u, true, RootModeIndicator.None, false, false, true)] - [DataRow(true, 1000u, true, RootModeIndicator.RootMode, false, false, false)] + [DataRow(true, 1000u, true, RootModeIndicator.None, false, false, false)] [DataRow(false, 0u, false, RootModeIndicator.RunningAsRoot, false, false, false)] [DataRow(true, 0u, false, RootModeIndicator.RunningAsRoot, false, false, false)] [DataRow(false, 0u, true, RootModeIndicator.RunningAsRoot, false, false, false)] @@ -58,6 +58,26 @@ public void RootEnvironmentAlwaysReplacesInheritedUserStorageAndSession() Assert.ThrowsExactly(() => RootStartupEnvironment.GetOverrides("/")); } + [TestMethod] + [DataRow("XDG_DATA_DIRS", "/usr/local/share:/usr/share")] + [DataRow("XDG_CONFIG_DIRS", "/etc/xdg")] + [DataRow("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")] + [DataRow("TERMINAL", null)] + [DataRow("EDITOR", null)] + [DataRow("VISUAL", null)] + [DataRow("BROWSER", null)] + [DataRow("SHELL", null)] + [DataRow("XDG_CURRENT_DESKTOP", null)] + public void RootEnvironmentReplacesInheritedDiscoveryAndExecutableOverrides(string variable, string? expected) + { + var inherited = new Dictionary { [variable] = "/home/user/untrusted" }; + foreach (var entry in RootStartupEnvironment.GetOverrides("/root")) inherited[entry.Key] = entry.Value; + Assert.AreEqual(expected, inherited[variable]); + var directories = new Files.Platform.Linux.Mime.XdgDirectories(name => inherited.GetValueOrDefault(name)); + CollectionAssert.AreEqual(new[] { "/usr/local/share", "/usr/share" }, new List(directories.DataDirs)); + CollectionAssert.AreEqual(new[] { "/etc/xdg" }, new List(directories.ConfigDirs)); + } + private sealed class Inspector : IFileOwnershipInspector { public Dictionary Entries { get; } = new()