diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index adfe8cfbfcef..47a5be3a539b 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. +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)** 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; 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 75e2362d0584..1afcde575e5b 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 @@ -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 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. **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. `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. 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 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. @@ -52,3 +64,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..3464f2a7c0b5 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` | -| 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_*` | +| 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` 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`. 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 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. @@ -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 `. 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/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/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/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs index b623e29121c1..5058fb9648cd 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/Navigation/NavigationHelpers.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs index a35e7c7f67dc..5c3a12f38b13 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; @@ -442,6 +446,10 @@ private static Task LaunchNewLinuxInstance() startInfo.Environment["FILES_NO_SINGLE_INSTANCE"] = "1"; startInfo.ArgumentList.Add("--new-window"); +#if !WINDOWS + if (Files.Platform.Linux.Elevation.RootActionMode.IsRequested(Program.LaunchArguments)) + startInfo.ArgumentList.Add("--root"); +#endif System.Diagnostics.Process.Start(startInfo)?.Dispose(); } catch (Exception ex) diff --git a/src/Files.App/Helpers/RootActionsHelper.cs b/src/Files.App/Helpers/RootActionsHelper.cs index 487048233881..6a2fd57b5e73 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,31 +14,42 @@ 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 { 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); + + private static Task? terminalAvailability; + + 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; 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/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.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 a7707c0655b3..2ca07bf589a2 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -5051,4 +5051,13 @@ This service-menu action runs a separate command for each item. Select at most {0} items and try again. + + Open in terminal as root + + + (Root mode) + + + (Running 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/ElevationTrust.cs b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs index 7a8b05ee6eab..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); } @@ -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. /// @@ -132,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/PkexecElevationService.cs b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs index 92b6e0544b4b..8aec117b86e2 100644 --- a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs +++ b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs @@ -165,12 +165,10 @@ 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; + 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..14e593b77544 --- /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 && !disabled ? 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 new file mode 100644 index 000000000000..c6918ba20a5a --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/RootActionsAvailability.cs @@ -0,0 +1,24 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +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 || + Environment.GetEnvironmentVariable("FILES_DISABLE_ROOT_ACTIONS") == "1"; + } +} diff --git a/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs new file mode 100644 index 000000000000..3f8ddd233ba4 --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/RootStartupEnvironment.cs @@ -0,0 +1,72 @@ +// 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_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, + ["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/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 9c678dcf5d57..f9274b4ea0ec 100644 --- a/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs +++ b/src/Files.Platform.Linux/Launching/LinuxLauncherService.cs @@ -22,16 +22,20 @@ public sealed class LinuxLauncherService : ILauncherService private readonly IApplicationRegistry applications; private readonly IProcessStarter starter; private readonly TerminalResolver terminals; + private readonly RootTerminalResolver rootTerminals; + private readonly Lazy rootTerminal; /// /// 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(); + rootTerminal = new Lazy(terminals.Resolve); } /// @@ -167,6 +171,19 @@ public Task OpenTerminalAsync(string folderPath, CancellationToken cancell return TryStartAsync(new ProcessLaunch(terminal.FileName, terminal.BuildOpenArguments(folderPath), folderPath), cancellationToken); } + 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 = 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); + } + /// 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..3918132ded49 --- /dev/null +++ b/src/Files.Platform.Linux/Launching/RootTerminalResolver.cs @@ -0,0 +1,89 @@ +// Copyright (c) Files Community +// 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 sealed record Configuration(string Tool, string? Shell = null, bool KeepWorkingDirectory = false); + private readonly Func disabled; + private readonly Lazy configuration; + + public RootTerminalResolver() : this( + new SystemToolResolver(new ElevationPathChecker(new StatxFileOwnershipInspector(), ProcessIdentityNative.CurrentUserId)), + new PathExecutableLocator(), Environment.GetEnvironmentVariable, () => !RootActionsAvailability.Mode.AllowRootTerminal) { } + + public RootTerminalResolver(ITrustedToolResolver tools, IExecutableLocator locator, Func environment, + Func disabled, Func? version = null) + { + this.disabled = disabled; + configuration = new Lazy(() => + { + if (tools.Resolve("run0") is { } run0) return new(run0); + if (tools.Resolve("sudo") is { } sudo) return new(sudo); + 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() || 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/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..d9fda36c686b 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; @@ -74,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)) { @@ -103,7 +105,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..34fe65459821 100644 --- a/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs +++ b/src/Files.Platform.Linux/Mime/ServiceMenuParser.cs @@ -2,10 +2,12 @@ // 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.Text; namespace Files.Platform.Linux.Mime { @@ -25,7 +27,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 +40,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) @@ -70,14 +73,35 @@ public static class ServiceMenuParser return null; 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/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/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..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; } = []; @@ -43,7 +82,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(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); } @@ -138,6 +178,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 FakeTools("run0", "sudo", "pkexec"), new FakeLocator(), _ => 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..13ebbe681d9f --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/README.md @@ -0,0 +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; line endings are CRLF and Ghostty's trailing blank line is removed. + +Attribution below records the installed source package/version and its declared licence from the package database. These third-party fixtures retain their upstream licences. + +| 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 new file mode 100644 index 000000000000..7a0e8f93559e --- /dev/null +++ b/tests/Files.Platform.Tests/Mime/Fixtures/ServiceMenus/com.mitchellh.ghostty.desktop @@ -0,0 +1,10 @@ +[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..2bf84423f670 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] @@ -294,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() { @@ -312,6 +339,101 @@ 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); + 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"); + 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_DistroGhosttyUsesLiteralLongOptionWithoutRewritingExec() + { + 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], CultureInfo.InvariantCulture); + Assert.IsNotNull(plan); + 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(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); + } + + [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=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); + 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; } = []; diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs index 7fa8c3c96d59..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; @@ -112,6 +143,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..9f2f0da19898 --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/RootActionModeTests.cs @@ -0,0 +1,107 @@ +// 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.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)] + [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("/")); + } + + [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() + { + ["/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)); + } + } +}