From 065e6dd83e5b6c9b563cfee2734e594e4cdd3e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 12 Oct 2026 18:45:26 +0300 Subject: [PATCH 1/4] Fix thumbnail validation, archive links, no-replace renames and packaging safety Co-Authored-By: Codex Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- docs/linux-port/packaging.md | 16 ++-- packaging/linux/aur/linuxfiles-bin/PKGBUILD | 10 ++- packaging/linux/aur/linuxfiles/PKGBUILD | 8 +- scripts/linux/gen-aur.sh | 53 +++++++++---- scripts/linux/install-local.sh | 22 +++++- .../linux/tests/test_security_packaging.py | 57 ++++++++++++++ .../Archives/ArchiveSource.cs | 4 +- .../Archives/LinuxArchiveService.Extract.cs | 3 +- .../Native/PosixFileOperationsNative.cs | 7 +- .../Thumbnails/LinuxThumbnailService.cs | 77 ++++++++++++------- .../Archives/ArchiveServiceTests.cs | 17 ++++ .../FileOperations/FdCompatibilityTests.cs | 6 +- .../FileOperations/FdRenameFollowUpTests.cs | 11 +-- .../Thumbnails/ThumbnailTests.cs | 27 +++++++ 14 files changed, 249 insertions(+), 69 deletions(-) create mode 100644 scripts/linux/tests/test_security_packaging.py diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index 49a914f1e8e4..d9afd70ec88e 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -12,7 +12,7 @@ App id (used everywhere): `io.github.memergamer.LinuxFiles`. Binary/launcher: `f | `packaging/linux/icons/hicolor/x/apps/` | Icons 16, 24, 32, 48, 64, 128, 256, 512 (PNG). | | `packaging/linux/files` | Launcher: finds `Files.dll` (`FILES_LIBDIR`, next to itself, `../share/linuxfiles`, `/usr/lib/linuxfiles`, ...) and execs the apphost if present, else `dotnet Files.dll`. | | `packaging/linux/flatpak/` | Flatpak manifest (repackages the publish tarball). | -| `packaging/linux/aur/linuxfiles-bin/PKGBUILD` | AUR binary package (release tarball); `linuxfiles/PKGBUILD` is the unverified from-source variant. | +| `packaging/linux/aur/linuxfiles-bin/PKGBUILD` | Guarded AUR binary and source recipe templates; generate checksum-pinned recipes before use. | | `packaging/linux/appimage/build-appimage.sh` | AppImage builder; downloads pinned `appimagetool` + type2 runtime into `.cache/tools`. | | `scripts/linux/build-flatpak.sh`, `gen-aur.sh` | Build the Flatpak bundle; render PKGBUILD + `.SRCINFO` with real checksums. | | `scripts/linux/publish.sh` | `dotnet publish` to `artifacts/linux-/`. | @@ -120,12 +120,14 @@ found, so a launch that could not see the folders retries instead of freezing an `packaging/linux/aur/linuxfiles-bin/PKGBUILD` repackages the release tarballs (`files-linux-x64.tar.gz` plus `files-packaging.tar.gz`, which carries the desktop entry, metainfo, icons, launcher and `LICENSE-MIT`) into -`/usr/lib/linuxfiles` + `/usr/bin/files`; x86_64 only. The checked-in `sha256sums` are `SKIP`: -`scripts/linux/gen-aur.sh [out]` renders a PKGBUILD with real sums and `.SRCINFO` (CI does this on -tags and uploads both; push them to the AUR repo by hand). `AUR_BASE_URL=file:///dir` points the sources at local -files, which is how the package was build-tested with `makepkg` here (resulting file list checked). `namcap` is not -installed on the dev box, so the package is not linted. `aur/linuxfiles/PKGBUILD` (from source) is unchanged and -unbuilt. +`/usr/lib/linuxfiles` + `/usr/bin/files`; x86_64 only. Both checked-in recipes are guarded templates: +they fail when sourced until `scripts/linux/gen-aur.sh [out] [binary|source]` +generates a recipe with SHA-256 checksums and `.SRCINFO`. Binary mode (the default) uses the two release +assets above; source mode requires the exact GitHub tag archive saved as `linuxfiles-.tar.gz`. +Obtain the input artifacts from a trusted release before generating checksums. CI generates the binary +recipe on tags and uploads it; the AUR remote is updated separately. `AUR_BASE_URL=file:///dir` can +point generated recipes at local inputs for offline testing. Native package builds and `namcap` validation +remain separate release checks. ### Publishing a release (exact procedure) diff --git a/packaging/linux/aur/linuxfiles-bin/PKGBUILD b/packaging/linux/aur/linuxfiles-bin/PKGBUILD index 147ca90a207d..28590f2319b7 100644 --- a/packaging/linux/aur/linuxfiles-bin/PKGBUILD +++ b/packaging/linux/aur/linuxfiles-bin/PKGBUILD @@ -26,8 +26,8 @@ _base="$url/releases/download/linux-v$_tag" source=("$pkgname-$pkgver.tar.gz::$_base/files-linux-x64.tar.gz" "$pkgname-packaging-$pkgver.tar.gz::$_base/files-packaging.tar.gz") noextract=("$pkgname-$pkgver.tar.gz" "$pkgname-packaging-$pkgver.tar.gz") -sha256sums=('SKIP' - 'SKIP') # scripts/linux/gen-aur.sh fills these in +sha256sums=('UNGENERATED' + 'UNGENERATED') prepare() { mkdir -p app packaging @@ -55,3 +55,9 @@ package() { done install -Dm644 packaging/LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT" } + +# This checked-in template is intentionally not consumable by makepkg. +if [[ " ${sha256sums[*]} " == *UNGENERATED* ]]; then + printf '%s\n' 'Generate a checksum-pinned recipe with scripts/linux/gen-aur.sh before building.' >&2 + return 1 2>/dev/null || exit 1 +fi diff --git a/packaging/linux/aur/linuxfiles/PKGBUILD b/packaging/linux/aur/linuxfiles/PKGBUILD index 316143e4397f..943e3c00c83a 100644 --- a/packaging/linux/aur/linuxfiles/PKGBUILD +++ b/packaging/linux/aur/linuxfiles/PKGBUILD @@ -14,7 +14,7 @@ makedepends=('dotnet-sdk>=10.0' 'git' 'clang' 'zlib') optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives') options=('!strip') # .NET single-file/self-contained binaries break when stripped source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/linux-v$pkgver.tar.gz") -sha256sums=('SKIP') # replace with the real checksum on release +sha256sums=('UNGENERATED') build() { cd "LinuxFiles-linux-v$pkgver" @@ -52,3 +52,9 @@ package() { done install -Dm644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT" } + +# This checked-in template is intentionally not consumable by makepkg. +if [[ " ${sha256sums[*]} " == *UNGENERATED* ]]; then + printf '%s\n' 'Generate a checksum-pinned recipe with scripts/linux/gen-aur.sh before building.' >&2 + return 1 2>/dev/null || exit 1 +fi diff --git a/scripts/linux/gen-aur.sh b/scripts/linux/gen-aur.sh index 53bbb5f7725d..b55952239744 100755 --- a/scripts/linux/gen-aur.sh +++ b/scripts/linux/gen-aur.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Render the linuxfiles-bin PKGBUILD + .SRCINFO for a release. -# Usage: scripts/linux/gen-aur.sh [out-dir] +# Render a checksum-pinned binary or source PKGBUILD + .SRCINFO for a release. +# Usage: scripts/linux/gen-aur.sh [out-dir] [binary|source] # holds files-linux-x64.tar.gz and files-packaging.tar.gz (artifacts/ after publish.sh --tarball # plus the packaging bundle). Output: /PKGBUILD and .SRCINFO (default artifacts/aur). +# Source mode requires linuxfiles-.tar.gz, the exact GitHub tag archive. # AUR_BASE_URL overrides the download base (e.g. file:///path) to test the build with makepkg offline. set -euo pipefail @@ -14,21 +15,44 @@ if [[ -n "${AUR_BASE_URL:-}" && ! "$AUR_BASE_URL" =~ ^[A-Za-z0-9:/._~%@+-]+$ ]]; fi dir="${2:?directory with release tarballs required}" out="${3:-$root/artifacts/aur}" -tpl="$root/packaging/linux/aur/linuxfiles-bin/PKGBUILD" +kind="${4:-binary}" +case "$kind" in + binary) tpl="$root/packaging/linux/aur/linuxfiles-bin/PKGBUILD" ;; + source) tpl="$root/packaging/linux/aur/linuxfiles/PKGBUILD" ;; + *) echo "Recipe kind must be binary or source" >&2; exit 2 ;; +esac -sum() { sha256sum "$dir/$1" | cut -d' ' -f1; } -bin_sum="$(sum files-linux-x64.tar.gz)" -pkg_sum="$(sum files-packaging.tar.gz)" +sum() { sha256sum < "$dir/$1" | cut -d' ' -f1; } +if [[ "$kind" == source ]]; then + bin_sum="$(sum "linuxfiles-$tag.tar.gz")" + pkg_sum="$bin_sum" +else + bin_sum="$(sum files-linux-x64.tar.gz)" + pkg_sum="$(sum files-packaging.tar.gz)" +fi mkdir -p "$out" -python3 - "$tpl" "$out/PKGBUILD" "$tag" "$bin_sum" "$pkg_sum" "${AUR_BASE_URL:-}" <<'PY' +python3 - "$tpl" "$out/PKGBUILD" "$tag" "$bin_sum" "$pkg_sum" "${AUR_BASE_URL:-}" "$kind" <<'PY' import re, sys -tpl, dst, ver, a, b, base = sys.argv[1:7] +tpl, dst, ver, a, b, base, kind = sys.argv[1:8] +if not all(re.fullmatch(r'[0-9a-f]{64}', digest) for digest in (a, b)): + raise SystemExit('Invalid release checksum') t = open(tpl).read() t = re.sub(r"^_tag=.*$", lambda m: "_tag=" + ver, t, flags=re.M) t = re.sub(r"^pkgver=.*$", lambda m: "pkgver=" + ver.replace("-", ""), t, flags=re.M) -t = re.sub(r"sha256sums=\('SKIP'\n\s+'SKIP'\)", lambda m: "sha256sums=('%s'\n '%s')" % (a, b), t) -if base: +if kind == 'source': + t = re.sub(r'^pkgver=.*$', lambda m: 'pkgver=' + ver.replace('-', ''), t, flags=re.M) + t = re.sub(r'^source=.*$', lambda m: 'source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/linux-v' + ver + '.tar.gz")', t, flags=re.M) + t = t.replace('LinuxFiles-linux-v$pkgver', 'LinuxFiles-linux-v' + ver) + checksums = [a] +else: + checksums = [a, b] +t = re.sub(r"sha256sums=\([^)]*\)", lambda m: "sha256sums=(" + ' '.join("'" + x + "'" for x in checksums) + ")", t) +if 'UNGENERATED' in re.search(r'^sha256sums=.*$', t, re.M).group() or re.search(r"['\"]SKIP['\"]", t): + raise SystemExit('Recipe checksums are not pinned') +if base and kind == 'source': + t = re.sub(r'^source=.*$', lambda m: 'source=("$pkgname-$pkgver.tar.gz::' + base + '/linuxfiles-' + ver + '.tar.gz")', t, flags=re.M) +if base and kind == 'binary': t = re.sub(r'^_base=.*$', lambda m: '_base="%s"' % base, t, flags=re.M) open(dst, "w").write(t) PY @@ -41,16 +65,17 @@ srcinfo() { ( # shellcheck disable=SC1091 source "$out/PKGBUILD" - list() { local k="$1"; shift; local v; for v in "$@"; do printf '\t%s = %s\n' "$k" "$v"; done; } + list() { local k="$1"; shift; local v; for v in "$@"; do [[ -n "$v" ]] || continue; printf '\t%s = %s\n' "$k" "$v"; done; } printf 'pkgbase = %s\n' "$pkgname" printf '\tpkgdesc = %s\n\tpkgver = %s\n\tpkgrel = %s\n\turl = %s\n' "$pkgdesc" "$pkgver" "$pkgrel" "$url" list arch "${arch[@]}" list license "${license[@]}" list depends "${depends[@]}" + list makedepends "${makedepends[@]-}" list optdepends "${optdepends[@]}" - list provides "${provides[@]}" - list conflicts "${conflicts[@]}" - list noextract "${noextract[@]}" + list provides "${provides[@]-}" + list conflicts "${conflicts[@]-}" + list noextract "${noextract[@]-}" list options "${options[@]}" list source "${source[@]}" list sha256sums "${sha256sums[@]}" diff --git a/scripts/linux/install-local.sh b/scripts/linux/install-local.sh index afb310ced584..da8dca67d6c0 100755 --- a/scripts/linux/install-local.sh +++ b/scripts/linux/install-local.sh @@ -22,6 +22,14 @@ done [[ -f "$from/Files.dll" ]] || { echo "No published build at $from (run scripts/linux/publish.sh)" >&2; exit 1; } +prefix="$(python3 - "$prefix" <<'PYTHON' +import os, sys +prefix = os.path.abspath(sys.argv[1]) +if any(c in prefix for c in '\r\n'): + raise SystemExit("Install prefix cannot contain line breaks") +print(prefix) +PYTHON +)" libdir="$prefix/share/linuxfiles" share="$prefix/share" @@ -33,8 +41,18 @@ install -Dm755 "$root/packaging/linux/files" "$prefix/bin/files" # The desktop entry gets an absolute Exec so it works even when ~/.local/bin is not on PATH. install -d "$share/applications" -sed "s|^Exec=files|Exec=$prefix/bin/files|" \ - "$root/packaging/linux/$app_id.desktop" > "$share/applications/$app_id.desktop" +python3 - "$root/packaging/linux/$app_id.desktop" "$share/applications/$app_id.desktop" "$prefix/bin/files" <<'PYTHON' +import sys +source, destination, executable = sys.argv[1:] +# Exec quoting is followed by desktop-entry string escaping. +quoted = '"' + ''.join('\\' + c if c in '\\"`$' else '%%' if c == '%' else c for c in executable) + '"' +quoted = quoted.replace('\\', '\\\\').replace('\t', '\\t') +with open(source) as entry, open(destination, 'w') as output: + for line in entry: + if line.startswith('Exec=files'): + line = 'Exec=' + quoted + line[len('Exec=files'):] + output.write(line) +PYTHON install -Dm644 "$root/packaging/linux/$app_id.metainfo.xml" "$share/metainfo/$app_id.metainfo.xml" diff --git a/scripts/linux/tests/test_security_packaging.py b/scripts/linux/tests/test_security_packaging.py new file mode 100644 index 000000000000..4d8ea1202b93 --- /dev/null +++ b/scripts/linux/tests/test_security_packaging.py @@ -0,0 +1,57 @@ +import hashlib +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] + + +class SecurityPackagingTests(unittest.TestCase): + def test_templates_fail_closed(self): + for name in ('linuxfiles', 'linuxfiles-bin'): + recipe = ROOT / 'packaging/linux/aur' / name / 'PKGBUILD' + result = subprocess.run(['bash', '-c', 'source "$1"', 'test', str(recipe)], capture_output=True) + self.assertNotEqual(0, result.returncode) + + def test_generates_pinned_source_and_binary(self): + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + for name in ('files-linux-x64.tar.gz', 'files-packaging.tar.gz', 'linuxfiles-1.2.3.tar.gz'): + (directory / name).write_bytes(name.encode()) + for kind in ('source', 'binary'): + out = directory / kind + subprocess.run(['bash', str(ROOT / 'scripts/linux/gen-aur.sh'), '1.2.3', str(directory), str(out), kind], check=True, capture_output=True, env={**os.environ, 'AUR_SRCINFO_FALLBACK': '1'}) + recipe = (out / 'PKGBUILD').read_text() + self.assertNotIn("'SKIP'", recipe) + names = ['linuxfiles-1.2.3.tar.gz'] if kind == 'source' else ['files-linux-x64.tar.gz', 'files-packaging.tar.gz'] + for name in names: + checksum = hashlib.sha256((directory / name).read_bytes()).hexdigest() + self.assertIn(checksum, recipe) + self.assertIn(checksum, (out / '.SRCINFO').read_text()) + subprocess.run(['bash', '-c', 'source "$1"', 'test', str(out / 'PKGBUILD')], check=True) + + def test_install_exec_round_trip(self): + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + source = directory / 'publish' + source.mkdir() + (source / 'Files.dll').touch() + tools = directory / 'tools' + tools.mkdir() + for name in ('update-desktop-database', 'gtk-update-icon-cache'): + (tools / name).write_text('#!/bin/sh\nexit 0\n') + (tools / name).chmod(0o755) + for suffix in ('space prefix', 'amp&ersand', 'pipe|prefix', 'quote"prefix', 'percent%f', 'back\\slash', 'dollar$`prefix'): + prefix = directory / suffix + subprocess.run(['bash', str(ROOT / 'scripts/linux/install-local.sh'), '--from', str(source), '--prefix', os.path.relpath(prefix)], check=True, capture_output=True, env={**os.environ, 'PATH': str(tools) + ':' + os.environ['PATH']}) + desktop = (prefix / 'share/applications/io.github.memergamer.LinuxFiles.desktop').read_text() + for line in desktop.splitlines(): + if line.startswith('Exec='): + value = line[5:] + # Undo desktop-entry escaping, then Exec argument quoting and literal percent codes. + value = value.replace('\\\\', '\\').replace('\\t', '\t') + executable = shlex.split(value)[0].replace('\\$', '$').replace('\\`', '`').replace('%%', '%') + self.assertEqual(str(prefix / 'bin/files'), executable) diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index e02a0c636c4a..986490288626 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -22,7 +22,7 @@ namespace Files.Platform.Linux.Archives /// /// An entry as read from an archive. Names, sizes and link targets are untrusted. /// - internal sealed record EntryData(string? Key, bool IsDirectory, long Size, long CompressedSize, DateTime? Modified, bool IsEncrypted, string? LinkTarget, long Crc, int? Attrib, bool IsTar); + internal sealed record EntryData(string? Key, bool IsDirectory, long Size, long CompressedSize, DateTime? Modified, bool IsEncrypted, string? LinkTarget, long Crc, int? Attrib, bool IsTar, bool IsHardLink = false); /// /// A readable archive, hiding which SharpCompress API (archive, tar reader, single compressed file) is behind it. @@ -196,7 +196,7 @@ public TarSource(string path, Codec codec, ReaderOptions options, long? maxBytes continue; } yield return (new EntryData(entry.Name, entry.EntryType == TarEntryType.Directory, entry.Length, 0, - entry.ModificationTime.UtcDateTime, false, link, 0, (int)entry.Mode, true), () => current.DataStream ?? Stream.Null); + entry.ModificationTime.UtcDateTime, false, link, 0, (int)entry.Mode, true, entry.EntryType == TarEntryType.HardLink), () => current.DataStream ?? Stream.Null); } } } diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs index 8343e1320128..c381aa4ddbbe 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs @@ -140,8 +140,9 @@ void ReportProgress(string? currentEntry, bool force = false) if (entry.LinkTarget is not null) { + // Hardlinks use archive-root targets and are reported as unsupported, never converted to symlinks. // Parent segments could escape through another archive link; accept only downward relative targets. - if (!ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || FileSystemEntry.GetKind(full) != EntryKind.None) + if (entry.IsHardLink || !ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || FileSystemEntry.GetKind(full) != EntryKind.None) { skipped++; continue; diff --git a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs index 99cb50589356..4d2aeefaef9e 100644 --- a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs +++ b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs @@ -127,10 +127,9 @@ private static bool TryCheckedRenameAt(int sourceFd, string source, int destinat } if (!IsNotFound(errno)) return false; - // Without RENAME_NOREPLACE, directories and entries that cannot be hardlinked retain a check/rename race. - var result = renameat(sourceFd, source, destinationFd, destination); - errno = result == 0 ? 0 : Marshal.GetLastPInvokeError(); - return result == 0; + // Never weaken the no-replace contract with a check followed by overwriting rename. + errno = 95; // EOPNOTSUPP + return false; } public static void RenameAt(int sourceFd, string source, int destinationFd, string destination, bool replace, string displayPath, LinuxFileOperationsHooks? hooks = null) diff --git a/src/Files.Platform.Linux/Thumbnails/LinuxThumbnailService.cs b/src/Files.Platform.Linux/Thumbnails/LinuxThumbnailService.cs index 711bf5293d11..4375999e2738 100644 --- a/src/Files.Platform.Linux/Thumbnails/LinuxThumbnailService.cs +++ b/src/Files.Platform.Linux/Thumbnails/LinuxThumbnailService.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Files.Platform.Abstractions.Thumbnails; +using Files.Platform.Linux.Native; using SkiaSharp; using System; using System.Collections.Generic; @@ -176,7 +177,7 @@ public LinuxThumbnailService(LinuxThumbnailOptions options) if (!options.HasFlag(ThumbnailOptions.ForceRegenerate)) { - var cached = TryReadValid(cachePath, uri, mtime, info.Length); + var cached = TryReadValid(cachePath, uri, mtime, info.Length, XdgThumbnailNaming.GetBucketSize(bucket)); if (cached is not null) return cached; @@ -231,20 +232,11 @@ public LinuxThumbnailService(LinuxThumbnailOptions options) /// public void Dispose() => _gate.Dispose(); - private byte[]? TryReadValid(string cacheFile, string uri, long mtime, long? size) + private byte[]? TryReadValid(string cacheFile, string uri, long mtime, long? size, int maxSize = 1) { - byte[] bytes; - try - { - if (new FileInfo(cacheFile).Length > _options.MaxCacheEntryBytes) - return null; - - bytes = File.ReadAllBytes(cacheFile); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { + var bytes = ReadBoundedPng(cacheFile); + if (bytes is null) return null; - } var text = PngTextChunks.Read(bytes); if (!text.TryGetValue("Thumb::URI", out var storedUri) || storedUri != uri) @@ -256,7 +248,31 @@ public LinuxThumbnailService(LinuxThumbnailOptions options) if (size is not null && text.TryGetValue("Thumb::Size", out var storedSize) && storedSize != size.Value.ToString(CultureInfo.InvariantCulture)) return null; - return bytes; + return ValidateAndReencode(bytes, maxSize); + } + + private byte[]? ReadBoundedPng(string path) + { + try + { + using var input = PosixNative.OpenFileAt(-100, path, path, false); + if (input.Length < 33 || input.Length > _options.MaxCacheEntryBytes) + return null; + using var output = new MemoryStream(); + var buffer = new byte[8192]; + int read; + while ((read = input.Read(buffer, 0, (int)Math.Min(buffer.Length, _options.MaxCacheEntryBytes - output.Length + 1))) > 0) + { + if (output.Length + read > _options.MaxCacheEntryBytes) + return null; + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } } // Larger buckets scale down cleanly, so they are tried first; the largest is last because decoding it is the most expensive. @@ -275,7 +291,7 @@ public LinuxThumbnailService(LinuxThumbnailOptions options) continue; var file = Path.Combine(_thumbnailRoot, other, hash + ".png"); - var bytes = TryReadValid(file, uri, mtime, size); + var bytes = TryReadValid(file, uri, mtime, size, XdgThumbnailNaming.GetBucketSize(other)); if (bytes is null) continue; @@ -482,24 +498,29 @@ private string GetThumbnailerTempRoot() /// private byte[]? ValidateAndReencode(string outputPath, int maxSize) { - var info = new FileInfo(outputPath); - if (!info.Exists || info.LinkTarget is not null || (info.Attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) != 0) - return null; + var bytes = ReadBoundedPng(outputPath); + return bytes is null ? null : ValidateAndReencode(bytes, maxSize); + } - if (info.Length > _options.MaxCacheEntryBytes || info.Length < 33) + private byte[]? ValidateAndReencode(byte[] bytes, int maxSize) + { + if (!PngTextChunks.TryReadDimensions(bytes, out var width, out var height) || width <= 0 || height <= 0 || width > maxSize || height > maxSize || (long)width * height > _options.MaxImagePixels) return null; - var bytes = File.ReadAllBytes(outputPath); - if (!PngTextChunks.TryReadDimensions(bytes, out var width, out var height) || width <= 0 || height <= 0 || width > maxSize || height > maxSize) - return null; + try + { + using var bitmap = SKBitmap.Decode(bytes); + if (bitmap is null || bitmap.Width != width || bitmap.Height != height) + return null; - using var bitmap = SKBitmap.Decode(bytes); - if (bitmap is null || bitmap.Width != width || bitmap.Height != height) + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + return encoded?.ToArray(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { return null; - - using var image = SKImage.FromBitmap(bitmap); - using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); - return encoded?.ToArray(); + } } private static bool ExecutableExists(string tryExec) diff --git a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs index c32d75807fc8..c0384a54d878 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs @@ -79,6 +79,23 @@ private void AssertNothingEscaped() Assert.AreEqual(0, Directory.GetFileSystemEntries(Out, "*", SearchOption.AllDirectories).Length, "destination must stay empty, staging removed"); } + [TestMethod] + public async Task TarHardlink_IsReportedSkippedRatherThanReinterpretedAsSymlink() + { + var archive = Path.Combine(Work, "hardlink.tar"); + using (var output = File.Create(archive)) + using (var writer = new TarWriter(output)) + { + using var data = new MemoryStream(Encoding.UTF8.GetBytes("base data")); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "base") { DataStream = data }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.HardLink, "dir/alias") { LinkName = "base" }); + } + var result = await service.ExtractAsync(archive, Out); + Assert.AreEqual(1, result.ItemsSkipped); + Assert.AreEqual("base data", File.ReadAllText(Path.Combine(Out, "base"))); + Assert.IsNull(new FileInfo(Path.Combine(Out, "dir/alias")).LinkTarget); + } + [TestMethod] public async Task PreviewListsUntrustedNamesWithoutExtracting() { diff --git a/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs b/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs index 72cf8b1aa148..7485040d5192 100644 --- a/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs +++ b/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs @@ -46,9 +46,9 @@ public async Task Rename2Unavailable_CopyMoveRenameAndMountCache(int errno) var directory = Path.Combine(Src, "tree"); Write(Path.Combine(directory, "nested", "child"), "nested data"); var movedDirectory = await service.MoveAsync([directory], Dst); - Assert.IsTrue(movedDirectory[0].Succeeded, movedDirectory[0].ErrorMessage); - Assert.AreEqual("nested data", File.ReadAllText(Path.Combine(Dst, "tree", "nested", "child"))); - Assert.IsFalse(Directory.Exists(directory)); + Assert.IsFalse(movedDirectory[0].Succeeded); + Assert.AreEqual("nested data", File.ReadAllText(Path.Combine(directory, "nested", "child"))); + Assert.IsFalse(Directory.Exists(Path.Combine(Dst, "tree"))); Assert.AreEqual(errno == 22 ? 1 : 4, attempts, "Only unambiguous EINVAL results are cached."); } diff --git a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs index 6adc765a4551..140d0b290866 100644 --- a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs +++ b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs @@ -24,7 +24,7 @@ public sealed class FdRenameFollowUpTests : FileOperationsTestBase [DataRow(95, true)] [DataRow(38, true)] [DataRow(31, true)] - public async Task HardlinkUnavailable_UsesCheckedRename(int linkError, bool conflict) + public async Task HardlinkUnavailable_FailsClosed(int linkError, bool conflict) { var source = Write(Path.Combine(Src, "entry"), "data"); var destination = Path.Combine(Dst, "entry"); @@ -49,9 +49,9 @@ public async Task HardlinkUnavailable_UsesCheckedRename(int linkError, bool conf } else { - Assert.IsTrue(result.Succeeded, result.ErrorMessage); - Assert.AreEqual("data", File.ReadAllText(destination)); - Assert.IsFalse(File.Exists(source)); + Assert.IsFalse(result.Succeeded); + Assert.IsFalse(File.Exists(destination)); + Assert.AreEqual("data", File.ReadAllText(source)); } } @@ -211,7 +211,8 @@ public async Task DirectoryEinval_DoesNotPoisonMountCache() { RenameError = (_, _, _) => { attempts++; return 22; }, }); - Assert.IsTrue((await service.RenameAsync(tree, "renamed-tree")).Succeeded); + Assert.IsFalse((await service.RenameAsync(tree, "renamed-tree")).Succeeded); + Assert.AreEqual("content", File.ReadAllText(Path.Combine(tree, "child"))); var file = Write(Path.Combine(Src, "file")); Assert.IsTrue((await service.RenameAsync(file, "renamed-file")).Succeeded); Assert.AreEqual(2, attempts, "Directory EINVAL must not suppress the next renameat2 attempt."); diff --git a/tests/Files.Platform.Tests/Thumbnails/ThumbnailTests.cs b/tests/Files.Platform.Tests/Thumbnails/ThumbnailTests.cs index dc88fc170a9e..32b3e74d8be1 100644 --- a/tests/Files.Platform.Tests/Thumbnails/ThumbnailTests.cs +++ b/tests/Files.Platform.Tests/Thumbnails/ThumbnailTests.cs @@ -59,6 +59,33 @@ private string CreateImage(string name, int width, int height) private string CachePath(string path, string bucket) => Path.Combine(_cacheHome, "thumbnails", bucket, XdgThumbnailNaming.GetHash(XdgThumbnailNaming.ToFileUri(path)) + ".png"); + [TestMethod] + [DataRow("oversized")] + [DataRow("corrupt")] + [DataRow("symlink")] + public async Task DirectCache_RejectsUntrustedImages(string kind) + { + var path = CreateImage("source.png", 1, 1); + var cache = CachePath(path, "normal"); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + var bytes = TestImages.CreatePng(kind == "oversized" ? 129 : 1, 1); + if (kind == "corrupt") + bytes = bytes[..33]; + bytes = PngTextChunks.Insert(bytes, + [new("Thumb::URI", XdgThumbnailNaming.ToFileUri(path)), + new("Thumb::MTime", new DateTimeOffset(File.GetLastWriteTimeUtc(path)).ToUnixTimeSeconds().ToString())]); + if (kind == "symlink") + { + var target = Path.Combine(_root, "outside.png"); + File.WriteAllBytes(target, bytes); + File.CreateSymbolicLink(cache, target); + } + else + File.WriteAllBytes(cache, bytes); + using var service = CreateService(); + Assert.IsNull(await service.GetThumbnailAsync(path, 128, ThumbnailOptions.ReturnOnlyIfCached)); + } + [TestMethod] public void Naming_MatchesSpecKnownVector() { From 9869ca2fb5822669ba479d723d6461f6761cd34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 12 Oct 2026 23:58:39 +0300 Subject: [PATCH 2/4] Address review findings Co-Authored-By: Codex Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Archives/ArchiveSource.cs | 16 ++++++- .../Launching/DesktopExecExpander.cs | 42 ++++++++++++++++++- .../Archives/ArchiveServiceTests.cs | 8 +++- .../Mime/ServiceMenuTests.cs | 12 ++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index 986490288626..88d83d1adb98 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -128,6 +128,14 @@ public ZipLikeSource(IArchive archive, Stream input) public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { + if (archive.Type == ArchiveType.Tar) + { + // Parse the detected format on the same opened input, regardless of its filename. + input.Position = 0; + foreach (var entry in TarSource.ReadEntries(input, default)) + yield return entry; + yield break; + } if (IsSolid && !headersOnly) { using var reader = archive.ExtractAllEntries(); @@ -180,7 +188,13 @@ public TarSource(string path, Codec codec, ReaderOptions options, long? maxBytes using var file = OpenInput(path, maxBytes, cancellationToken); using var codecStream = Decompress(file, codec); using var decompressed = maxBytes is { } limit ? new PreviewReadStream(codecStream, limit, cancellationToken) : codecStream; - using var reader = new TarReader(decompressed); + foreach (var entry in ReadEntries(decompressed, cancellationToken)) + yield return entry; + } + + internal static IEnumerable<(EntryData Entry, Func Open)> ReadEntries(Stream input, CancellationToken cancellationToken) + { + using var reader = new TarReader(input, leaveOpen: true); TarEntry? entry; var skipped = 0; while ((entry = reader.GetNextEntry()) is not null) diff --git a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs index 5bdc356b6839..123f1c4ea4f0 100644 --- a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs +++ b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs @@ -14,7 +14,7 @@ namespace Files.Platform.Linux.Launching /// public static class DesktopExecExpander { - private static readonly HashSet Shells = ["sh", "bash", "dash", "zsh", "ksh", "fish", "ash", "csh", "tcsh"]; + private static readonly HashSet Shells = ["sh", "bash", "dash", "zsh", "ksh", "fish", "ash", "csh", "tcsh", "rbash"]; /// /// Splits an Exec value into arguments using shell quoting rules (double quotes, single quotes, backslash). @@ -115,7 +115,7 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl if (app.Exec.Any(c => c is '|' or '&' or ';' or '<' or '>' or '`' or '$' or '*' or '?' or '~' or '#' || char.IsControl(c))) return []; var tokens = Tokenize(app.Exec); if (tokens is null || tokens.Count == 0 || tokens[0].Contains('%') || tokens[0].Contains('=') || - tokens.Any(t => Shells.Contains(System.IO.Path.GetFileName(t))) || + InvokesShell(tokens) || (System.IO.Path.GetFileName(tokens[0]) == "env" && tokens.Any(t => t.StartsWith("-S", StringComparison.Ordinal) || t.StartsWith("--split-string", StringComparison.Ordinal)))) 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 || @@ -165,6 +165,44 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl return result; } + private static bool InvokesShell(IReadOnlyList tokens) + { + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.Contains('=') || token.StartsWith('-') || token.Contains('%')) continue; + var executable = token.Contains('/') ? token : FindExecutable(token); + var name = System.IO.Path.GetFileName(token); + if (executable is not null) + name = System.IO.Path.GetFileName(Trash.LinuxTrashService.RealPath(executable)); + if (!Shells.Contains(name) && !Shells.Contains(System.IO.Path.GetFileName(token))) continue; + for (var j = i + 1; j < tokens.Count; j++) + { + var argument = tokens[j]; + if (argument == "--") return j + 1 < tokens.Count; + if (argument.StartsWith('-')) + { + if (!argument.StartsWith("--", StringComparison.Ordinal) && argument.Contains('c')) return true; + // These options consume an option name, rather than a script path. + if (argument is "-o" or "-O") j++; + continue; + } + return true; + } + } + return false; + } + + private static string? FindExecutable(string name) + { + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin").Split(':')) + { + var candidate = System.IO.Path.Combine(directory.Length == 0 ? "." : directory, name); + if (System.IO.File.Exists(candidate)) return candidate; + } + return null; + } + // Accept only --name=%x with an ASCII option name and no surrounding value text. private static char LongOptionTargetCode(string token) { diff --git a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs index c0384a54d878..fc82f6afc2ae 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs @@ -80,14 +80,18 @@ private void AssertNothingEscaped() } [TestMethod] - public async Task TarHardlink_IsReportedSkippedRatherThanReinterpretedAsSymlink() + [DataRow("hardlink.tar")] + [DataRow("payload.zip")] + public async Task TarHardlink_IsReportedSkippedRatherThanReinterpretedAsSymlink(string name) { - var archive = Path.Combine(Work, "hardlink.tar"); + var archive = Path.Combine(Work, name); using (var output = File.Create(archive)) using (var writer = new TarWriter(output)) { using var data = new MemoryStream(Encoding.UTF8.GetBytes("base data")); writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "base") { DataStream = data }); + using var attackerData = new MemoryStream(Encoding.UTF8.GetBytes("attacker data")); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "dir/base") { DataStream = attackerData }); writer.WriteEntry(new PaxTarEntry(TarEntryType.HardLink, "dir/alias") { LinkName = "base" }); } var result = await service.ExtractAsync(archive, Out); diff --git a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs index a5489484af0b..ac547102f5df 100644 --- a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs +++ b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs @@ -344,6 +344,18 @@ public void Expansion_TargetsRemainLiteralArgvElements(string field, bool urls, public void Expansion_RefusesShellsSyntaxAndEmbeddedTargetCodes(string exec) => Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), ["/a.png"]).Count); + [TestMethod] + public void Expansion_ResolvesShellAliasesInWrapperChains() + { + using var fx = new XdgFixture(); + var alias = Path.Combine(fx.Home, "interpreter"); + File.CreateSymbolicLink(alias, "/bin/sh"); + foreach (var exec in new[] { alias + " -c tool", "env " + alias + " script", "env env " + alias + " -c tool", "rbash -c tool" }) + Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), []).Count, exec); + foreach (var exec in new[] { "env VAR=bash tool", "tool bash", "bash --version" }) + Assert.AreEqual(1, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), []).Count, exec); + } + [TestMethod] [DataRow("f")] [DataRow("F")] From affbd858bf279fa245fbdb8ede010b4266004333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 13 Oct 2026 00:47:45 +0300 Subject: [PATCH 3/4] Address re-review findings Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Launching/DesktopExecExpander.cs | 42 +------------------ .../Native/PosixFileOperationsNative.cs | 9 ++-- .../FileOperations/FdCompatibilityTests.cs | 6 +-- .../FileOperations/FdRenameFollowUpTests.cs | 35 +++++++++++++--- .../Mime/ServiceMenuTests.cs | 12 ------ 5 files changed, 40 insertions(+), 64 deletions(-) diff --git a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs index 123f1c4ea4f0..5bdc356b6839 100644 --- a/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs +++ b/src/Files.Platform.Linux/Launching/DesktopExecExpander.cs @@ -14,7 +14,7 @@ namespace Files.Platform.Linux.Launching /// public static class DesktopExecExpander { - private static readonly HashSet Shells = ["sh", "bash", "dash", "zsh", "ksh", "fish", "ash", "csh", "tcsh", "rbash"]; + private static readonly HashSet Shells = ["sh", "bash", "dash", "zsh", "ksh", "fish", "ash", "csh", "tcsh"]; /// /// Splits an Exec value into arguments using shell quoting rules (double quotes, single quotes, backslash). @@ -115,7 +115,7 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl if (app.Exec.Any(c => c is '|' or '&' or ';' or '<' or '>' or '`' or '$' or '*' or '?' or '~' or '#' || char.IsControl(c))) return []; var tokens = Tokenize(app.Exec); if (tokens is null || tokens.Count == 0 || tokens[0].Contains('%') || tokens[0].Contains('=') || - InvokesShell(tokens) || + 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 []; 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 || @@ -165,44 +165,6 @@ public static IReadOnlyList> ExpandServiceMenu(DesktopAppl return result; } - private static bool InvokesShell(IReadOnlyList tokens) - { - for (var i = 0; i < tokens.Count; i++) - { - var token = tokens[i]; - if (token.Contains('=') || token.StartsWith('-') || token.Contains('%')) continue; - var executable = token.Contains('/') ? token : FindExecutable(token); - var name = System.IO.Path.GetFileName(token); - if (executable is not null) - name = System.IO.Path.GetFileName(Trash.LinuxTrashService.RealPath(executable)); - if (!Shells.Contains(name) && !Shells.Contains(System.IO.Path.GetFileName(token))) continue; - for (var j = i + 1; j < tokens.Count; j++) - { - var argument = tokens[j]; - if (argument == "--") return j + 1 < tokens.Count; - if (argument.StartsWith('-')) - { - if (!argument.StartsWith("--", StringComparison.Ordinal) && argument.Contains('c')) return true; - // These options consume an option name, rather than a script path. - if (argument is "-o" or "-O") j++; - continue; - } - return true; - } - } - return false; - } - - private static string? FindExecutable(string name) - { - foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin").Split(':')) - { - var candidate = System.IO.Path.Combine(directory.Length == 0 ? "." : directory, name); - if (System.IO.File.Exists(candidate)) return candidate; - } - return null; - } - // Accept only --name=%x with an ASCII option name and no surrounding value text. private static char LongOptionTargetCode(string token) { diff --git a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs index 9e060d5d9cd7..538c9d14f0bb 100644 --- a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs +++ b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs @@ -132,9 +132,12 @@ private static bool TryCheckedRenameAt(int sourceFd, string source, int destinat } if (!IsNotFound(errno)) return false; - // Never weaken the no-replace contract with a check followed by overwriting rename. - errno = 95; // EOPNOTSUPP - return false; + // Without RENAME_NOREPLACE, directories and entries that cannot be hardlinked retain a check/rename race. + var result = renameat(sourceFd, source, destinationFd, destination); + errno = result == 0 ? 0 : Marshal.GetLastPInvokeError(); + if (result != 0) + throw new IOException($"Checked rename failed for '{destination}' with errno {errno}: {CreateException(errno, destination).Message}", errno); + return true; } public static void RenameAt(int sourceFd, string source, int destinationFd, string destination, bool replace, string displayPath, LinuxFileOperationsHooks? hooks = null) diff --git a/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs b/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs index 7485040d5192..72cf8b1aa148 100644 --- a/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs +++ b/tests/Files.Platform.Tests/FileOperations/FdCompatibilityTests.cs @@ -46,9 +46,9 @@ public async Task Rename2Unavailable_CopyMoveRenameAndMountCache(int errno) var directory = Path.Combine(Src, "tree"); Write(Path.Combine(directory, "nested", "child"), "nested data"); var movedDirectory = await service.MoveAsync([directory], Dst); - Assert.IsFalse(movedDirectory[0].Succeeded); - Assert.AreEqual("nested data", File.ReadAllText(Path.Combine(directory, "nested", "child"))); - Assert.IsFalse(Directory.Exists(Path.Combine(Dst, "tree"))); + Assert.IsTrue(movedDirectory[0].Succeeded, movedDirectory[0].ErrorMessage); + Assert.AreEqual("nested data", File.ReadAllText(Path.Combine(Dst, "tree", "nested", "child"))); + Assert.IsFalse(Directory.Exists(directory)); Assert.AreEqual(errno == 22 ? 1 : 4, attempts, "Only unambiguous EINVAL results are cached."); } diff --git a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs index 140d0b290866..8d8cd5ea7038 100644 --- a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs +++ b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs @@ -24,7 +24,7 @@ public sealed class FdRenameFollowUpTests : FileOperationsTestBase [DataRow(95, true)] [DataRow(38, true)] [DataRow(31, true)] - public async Task HardlinkUnavailable_FailsClosed(int linkError, bool conflict) + public async Task HardlinkUnavailable_UsesCheckedRename(int linkError, bool conflict) { var source = Write(Path.Combine(Src, "entry"), "data"); var destination = Path.Combine(Dst, "entry"); @@ -49,9 +49,9 @@ public async Task HardlinkUnavailable_FailsClosed(int linkError, bool conflict) } else { - Assert.IsFalse(result.Succeeded); - Assert.IsFalse(File.Exists(destination)); - Assert.AreEqual("data", File.ReadAllText(source)); + Assert.IsTrue(result.Succeeded, result.ErrorMessage); + Assert.AreEqual("data", File.ReadAllText(destination)); + Assert.IsFalse(File.Exists(source)); } } @@ -201,6 +201,30 @@ public async Task ConflictingDestinationEinval_DoesNotPoisonMountCache() Assert.AreEqual(2, attempts); } + [TestMethod] + [DataRow(22)] + [DataRow(38)] + public async Task DirectoryFallback_InvalidMoveReportsCheckedRenameFailure(int errno) + { + var tree = Path.Combine(Src, "tree"); + Write(Path.Combine(tree, "child"), "preserved"); + var service = new LinuxFileOperationsService((_, _) => true, new LinuxFileOperationsHooks + { + RenameError = (_, _, _) => + { + // Move the already-open destination into the source after the service's path checks. + Directory.Move(Dst, Path.Combine(tree, "destination")); + return errno; + }, + }); + var result = (await service.MoveAsync([tree], Dst))[0]; + Assert.IsFalse(result.Succeeded); + StringAssert.Contains(result.ErrorMessage!, "Checked rename failed"); + StringAssert.Contains(result.ErrorMessage!, "errno"); + Assert.AreEqual("preserved", File.ReadAllText(Path.Combine(tree, "child"))); + Assert.IsFalse(Directory.Exists(Path.Combine(tree, "tree"))); + } + [TestMethod] public async Task DirectoryEinval_DoesNotPoisonMountCache() { @@ -211,8 +235,7 @@ public async Task DirectoryEinval_DoesNotPoisonMountCache() { RenameError = (_, _, _) => { attempts++; return 22; }, }); - Assert.IsFalse((await service.RenameAsync(tree, "renamed-tree")).Succeeded); - Assert.AreEqual("content", File.ReadAllText(Path.Combine(tree, "child"))); + Assert.IsTrue((await service.RenameAsync(tree, "renamed-tree")).Succeeded); var file = Write(Path.Combine(Src, "file")); Assert.IsTrue((await service.RenameAsync(file, "renamed-file")).Succeeded); Assert.AreEqual(2, attempts, "Directory EINVAL must not suppress the next renameat2 attempt."); diff --git a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs index ac547102f5df..a5489484af0b 100644 --- a/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs +++ b/tests/Files.Platform.Tests/Mime/ServiceMenuTests.cs @@ -344,18 +344,6 @@ public void Expansion_TargetsRemainLiteralArgvElements(string field, bool urls, public void Expansion_RefusesShellsSyntaxAndEmbeddedTargetCodes(string exec) => Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), ["/a.png"]).Count); - [TestMethod] - public void Expansion_ResolvesShellAliasesInWrapperChains() - { - using var fx = new XdgFixture(); - var alias = Path.Combine(fx.Home, "interpreter"); - File.CreateSymbolicLink(alias, "/bin/sh"); - foreach (var exec in new[] { alias + " -c tool", "env " + alias + " script", "env env " + alias + " -c tool", "rbash -c tool" }) - Assert.AreEqual(0, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), []).Count, exec); - foreach (var exec in new[] { "env VAR=bash tool", "tool bash", "bash --version" }) - Assert.AreEqual(1, DesktopExecExpander.ExpandServiceMenu(new DesktopApplication("run", "Run", exec, "/menu.desktop"), []).Count, exec); - } - [TestMethod] [DataRow("f")] [DataRow("F")] From 3a9be74b45e519d60fcaf8219643606f56fa1049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 13 Oct 2026 00:55:45 +0300 Subject: [PATCH 4/4] Keep copy-and-delete fallback on EXDEV Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016FAtbHHLQSiJiYcw2vnKiq --- .../Native/PosixFileOperationsNative.cs | 2 ++ .../FileOperations/FdRenameFollowUpTests.cs | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs index 538c9d14f0bb..6f867e3d5fb6 100644 --- a/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs +++ b/src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs @@ -135,6 +135,8 @@ private static bool TryCheckedRenameAt(int sourceFd, string source, int destinat // Without RENAME_NOREPLACE, directories and entries that cannot be hardlinked retain a check/rename race. var result = renameat(sourceFd, source, destinationFd, destination); errno = result == 0 ? 0 : Marshal.GetLastPInvokeError(); + if (IsCrossDevice(errno)) + return false; if (result != 0) throw new IOException($"Checked rename failed for '{destination}' with errno {errno}: {CreateException(errno, destination).Message}", errno); return true; diff --git a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs index 8d8cd5ea7038..5cd46782ae5e 100644 --- a/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs +++ b/tests/Files.Platform.Tests/FileOperations/FdRenameFollowUpTests.cs @@ -201,6 +201,42 @@ public async Task ConflictingDestinationEinval_DoesNotPoisonMountCache() Assert.AreEqual(2, attempts); } + [TestMethod] + [DataRow(22)] + [DataRow(38)] + public async Task DirectoryFallback_CrossDeviceMove_CopiesAndDeletesSource(int errno) + { + var statService = new Files.Platform.Linux.FileStat.LinuxFileStatService(); + if (!statService.TryGetFileId(Src, out var sourceId) + || !statService.TryGetFileId("/dev/shm", out var destinationId) + || sourceId.Device == destinationId.Device) + Assert.Inconclusive("A separate /dev/shm filesystem is required."); + + var destination = Path.Combine("/dev/shm", "files-rename-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + try + { + var tree = Path.Combine(Src, "tree"); + Write(Path.Combine(tree, "nested", "child"), "preserved"); + var attempts = 0; + // Simulate the same-mount assumption when mountinfo cannot be read. + var service = new LinuxFileOperationsService((_, _) => true, new LinuxFileOperationsHooks + { + RenameError = (_, _, _) => { attempts++; return errno; }, + }); + var result = (await service.MoveAsync([tree], destination))[0]; + Assert.IsTrue(result.Succeeded, result.ErrorMessage); + Assert.IsTrue(attempts > 0, "The rename compatibility fallback must be exercised."); + Assert.IsFalse(Directory.Exists(tree)); + Assert.AreEqual("preserved", File.ReadAllText(Path.Combine(destination, "tree", "nested", "child"))); + Assert.IsEmpty(Directory.GetFileSystemEntries(Src)); + } + finally + { + Directory.Delete(destination, true); + } + } + [TestMethod] [DataRow(22)] [DataRow(38)]