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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Files.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
</Project>
</Folder>
<Folder Name="/src/platforms/">
<Project Path="src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj" />
<Project Path="src/Files.App.BackgroundTasks/Files.App.BackgroundTasks.csproj">
<Platform Solution="*|arm64" Project="arm64" />
<Platform Solution="*|x64" Project="x64" />
Expand Down
8 changes: 8 additions & 0 deletions docs/linux-port/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,11 @@ Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nig
- Whether an SVG logo can be supplied for `scalable/`.
- Runtime behaviour of the Flatpak (sandboxed run, portals) and a `namcap` lint of the AUR package.
- arm64 publishing has not been tested.

## 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.

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 <published-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 <published-helper-dir> --sha256 <published-helper-sha256> --destdir <temporary-staging-dir>`. 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.
54 changes: 54 additions & 0 deletions docs/linux-port/threat-model-elevation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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.

## Privilege boundary

The only elevated executable is `/usr/lib/linuxfiles/files-elevation-helper`, a small .NET 10 Native AOT console program. Native packages install it root-owned, mode 0755, with root-owned ancestors that are not writable by group/others. It has no setuid bit. The matching `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` requires `auth_admin` for an active session; other sessions are denied. Authorization is not retained (`auth_admin_keep` is never used).

Files executes the trusted absolute pkexec path with the fixed helper path followed by two arguments: a sanitized human-readable summary (operation, source count and first source, absolute target) and the SHA-256 hex digest of the exact UTF-8 stdin JSON. It sends the confirmed plan on stdin, closes stdin, and reads JSON results capped at 512 KiB. It never executes rm, cp, mv, a shell, commands from JSON, or programs found through PATH. The subprocess starts with a cleared environment and LANG=C. pkexec removes dangerous loader variables before launching the AOT executable; the helper requires effective uid 0 and a non-root PKEXEC_UID, then clears its environment, sets umask 0077 and changes cwd to `/`. Before opening paths, it recomputes the digest from the raw stdin bytes, strictly parses the request and checks the summary against the parsed request. Missing/mismatched arguments are refused. It takes no command-line switches and has no daemon, plugin loading, configuration files or test mode.

The polkit prompt is the authorization boundary against a compromised session. An in-app confirmation is useful UI, but malware in the same session can invoke pkexec directly, so that confirmation cannot authorize privileged execution. The policy explicitly states that authentication grants root file access to the listed paths and their contents, and includes `$(command_line)` and `$(program)`. The summary is checked rather than trusted, and the digest binds all request bytes (including additional batch sources) to that invocation. Only the first source is named in the prompt; approving a batch authorizes the count and digest too. Administrators should approve only expected operations and targets; authentication agents must display the full policy message. Bidi, control and zero-width characters become visible escapes; paths are quoted as JSON without truncation. The summary is display data, never a shell command or authorization rule expression.

The supported minimum is **polkit 0.105** (with distributor security fixes). Upstream's [0.105 pkexec manual source](https://github.com/polkit-org/polkit/blob/0.105/docs/man/pkexec.xml) explicitly documents expansion of all three message variables for an annotated action. Its [pkexec source](https://github.com/polkit-org/polkit/blob/0.105/src/programs/pkexec.c) joins argv with spaces, sets `program` and `command_line` details, and overrides `polkit.message` only for the default action, leaving our custom policy message intact. The [current manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html) also documents expansion and exit codes 126 (dismissed) / 127 (not authorized or authorization error); both produce a dismissed/not-authorized result in Files. This is a supported minimum verified in source, not a claim about when expansion first appeared. The same holds in polkit 127: polkitd's `get_localized_data_for_challenge` runs `expand_properties` over the action message, and `$(command_line)` is the untruncated argv (unlike pkexec's own `$(cmdline_short)`).

Cancellation tries to stop pkexec, but a root child can reject killing with EPERM. Files drains bounded output and waits for exit without cancellation, then reports the actual helper result; it never labels running root work cancelled or failed. Even oversized output is drained after a denied kill before failure is reported. Unexpected top-level helper exceptions fail closed with exit 1 and a generic protocol error.

## Frozen confirmation and protocol

ElevationPlanPreview copies the plan into read-only collections and shows the exact operation, absolute sources and target encoded as readable Unicode JSON (display only; the strict wire serializer is unchanged), together with the executable and the stdin marker. DisplaySanitizer escapes control, bidi and zero-width characters; oversized previews cannot be confirmed. Confirmation hands out that displayed plan once. Cancel, close and Escape do nothing; Cancel is the default button. Only one dialog can be open.

The application compares the complete serialized request with the displayed command data before sending it. Its path checks are UI preflight only. It never trusts unprivileged post-operation filesystem checks, including for destinations under `/root`.

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.

## 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.

The complete source trees and destination directory are pinned before mutation. Every entry is checked with statx before and after opening, requiring identity, mode, uid, size, timestamps and mount id. Sources are regular files or directories only: symlinks (including inside trees), special files and mount crossings are refused. Tree walks stop at 4096 entries or depth 64; descriptor exhaustion and enumeration errors are failures. Invalid UTF-8 names are refused, rather than converted into different names. The helper checks source/destination containment by descriptor identity as well as by path.

- Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do.
- Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper.
- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Handover never widens access: files get at most the source mode & 0644 and directories the source mode & 0755 (a 0700 folder stays 0700). Group bits survive only when the source group equals the caller's primary group; otherwise they are cleared and the other bits are also limited to what the source group had, because former group members become "others". Group and other bits are cleared entirely when the source carries a POSIX access ACL (its mode group bits are only a mask) or when any directory from `/` to the source's parent does not let every non-root user search it: that requires owner, group and other search bits (a matching class never falls through to "other") and no access ACL. Ancestor ACLs are read through a read-only reopen of each pinned O_PATH descriptor, checked against its pinned identity; a failed reopen or ACL read counts as unreachable. This is deliberately narrower than strictly necessary for group-only paths. Multiply-linked files are refused before any output is created when handing over, because another link may alias data outside the approved tree. Metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. Until handover, outputs are root-owned 0600/0700 even inside a caller-owned destination (and an inherited default ACL gets an empty mask), so the caller cannot read partial or unverified copies. At handover the inherited access ACL is removed before fchmod (ENODATA/EOPNOTSUPP mean none) and its absence is verified, so the new mode cannot activate inherited named entries; a handed-over folder keeps its inherited default ACL, which only shapes files the caller later creates; a failed handover leaves a root-owned 0700 parent around any already handed-over children. No original ownership, timestamps, xattrs (including file capabilities and security labels) or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again.
- Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success.

Partial outputs remain in place and are reported as failures. Per-item errors carry only a fixed helper message, a generic not-found/access-denied text or an errno; they never echo entry names or raw name bytes from inside a tree, so a failure cannot list nested root-only names the caller was not shown. Every helper descriptor, including directory-listing duplicates (F_DUPFD_CLOEXEC), is close-on-exec, and the helper starts no child processes. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success.

## Races and residual limits

An ancestor replaced by a symlink during polkit authentication is refused. An ancestor renamed/replaced after opening cannot redirect the operation: it acts on the original pinned directory, even if its pathname now displays another object. Destination collisions never replace existing objects; copy verification concerns the helper-created inode, not an unrelated file that happens to exist.

Authentication authorizes the absolute plan after the prompt; the app does not attest every source inode before authentication. Concurrent modifications to regular file contents or directory entries are rejected when detected by identity, metadata, membership and byte checks. Linux has no atomic compare-inode-and-unlink primitive and no snapshot of concurrently writable file contents: a writer retaining access can still race a final check, and operations are not a transaction. No symlink race can escape the pinned parents, and no unexpected verification failure is accepted. For move, modifications detected before deletion preserve the affected original; undetectable writes after its final check remain a limitation. Users should quiesce writers before requesting privileged moves.

Root authentication intentionally permits destructive actions on root-owned data. An administrator already able to replace the installed executable/policy or perform privileged mount operations is outside this boundary. Kernel/glibc with statx mount identity and renameat2 support are required; missing required facilities disable/fail the operation rather than weakening checks.

## Packaging and verification

AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. The opt-in installer is equivalent to `sudo make install`: it executes administrator-trusted installer code and installs administrator-trusted build output, not a security boundary against a malicious checkout or build. It embeds the fixed policy, resolves destdir before checking for live `/`, and copies the helper through a no-follow regular-file descriptor to a private installer-owned temporary directory (root-owned for live installs) before checking its SHA-256 and ELF header and installing from that snapshot. The user-side wrapper supplies the pre-sudo helper digest; no helper is executed during installation. Non-root staging uses the same snapshot checks and never invokes sudo.

Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, permission masking for every class (private folders, foreign groups, ACLs, unreachable sources and ACL-bearing ancestors), inherited default ACL removal when setfacl and filesystem ACLs are available, hard-link refusal, name-free error text, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display.

## 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.
5 changes: 5 additions & 0 deletions packaging/linux/appimage/build-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ fetch "https://github.com/AppImage/type2-runtime/releases/download/20251108/runt
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin" "$appdir/usr/lib/linuxfiles" "$appdir/usr/share"
cp -a "$from/." "$appdir/usr/lib/linuxfiles/"
# Portable images cannot install the host polkit policy or privileged helper.
rm -rf "$appdir/usr/lib/linuxfiles/elevation-helper"
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"
install -Dm644 "$root/packaging/linux/$app_id.metainfo.xml" "$appdir/usr/share/metainfo/$app_id.metainfo.xml"
Expand All @@ -68,6 +72,7 @@ cat > "$appdir/AppRun" <<'RUN'
#!/bin/sh
here="$(dirname "$(readlink -f "$0")")"
export FILES_LIBDIR="$here/usr/lib/linuxfiles"
export FILES_DISABLE_ROOT_ACTIONS=1
exec "$here/usr/bin/files" "$@"
RUN
chmod +x "$appdir/AppRun"
Expand Down
7 changes: 6 additions & 1 deletion packaging/linux/aur/linuxfiles-bin/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ url='https://github.com/MemerGamer/LinuxFiles'
license=('MIT')
# The runtime is bundled; these are the native libraries Skia/Uno load at runtime.
depends=('fontconfig' 'freetype2' 'libx11' 'libxcursor' 'libxrandr' 'libxi' 'libxext' 'mesa' 'glib2' 'hicolor-icon-theme')
optdepends=('gvfs: network and MTP locations'
optdepends=('polkit: authenticated root actions'
'gvfs: network and MTP locations'
'udisks2: mount and eject drives'
'libsecret: saved network credentials (Secret Service provider)'
'gnome-disk-utility: format and manage drives')
Expand All @@ -39,6 +40,10 @@ package() {

install -dm755 "$pkgdir/usr/lib/$_pkgname"
cp -a app/. "$pkgdir/usr/lib/$_pkgname/"
install -Dm755 app/elevation-helper/files-elevation-helper "$pkgdir/usr/lib/linuxfiles/files-elevation-helper"
rm -rf "$pkgdir/usr/lib/$_pkgname/elevation-helper"
install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy \
"$pkgdir/usr/share/polkit-1/actions/io.github.memergamer.LinuxFiles.root-actions.policy"
# The launcher finds /usr/lib/linuxfiles/Files.dll on its own.
install -Dm755 packaging/linux/files "$pkgdir/usr/bin/files"

Expand Down
Loading
Loading