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
16 changes: 9 additions & 7 deletions docs/linux-port/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ App id (used everywhere): `io.github.memergamer.LinuxFiles`. Binary/launcher: `f
| `packaging/linux/icons/hicolor/<N>x<N>/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-<rid>/`. |
Expand Down Expand Up @@ -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 <version> <dir> [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 <version> <dir> [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-<version>.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)

Expand Down
10 changes: 8 additions & 2 deletions packaging/linux/aur/linuxfiles-bin/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion packaging/linux/aur/linuxfiles/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
53 changes: 39 additions & 14 deletions scripts/linux/gen-aur.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Render the linuxfiles-bin PKGBUILD + .SRCINFO for a release.
# Usage: scripts/linux/gen-aur.sh <version> <dir-with-release-tarballs> [out-dir]
# Render a checksum-pinned binary or source PKGBUILD + .SRCINFO for a release.
# Usage: scripts/linux/gen-aur.sh <version> <dir-with-release-tarballs> [out-dir] [binary|source]
# <dir> holds files-linux-x64.tar.gz and files-packaging.tar.gz (artifacts/ after publish.sh --tarball
# plus the packaging bundle). Output: <out-dir>/PKGBUILD and .SRCINFO (default artifacts/aur).
# Source mode requires linuxfiles-<version>.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

Expand All @@ -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
Expand All @@ -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[@]}"
Expand Down
22 changes: 20 additions & 2 deletions scripts/linux/install-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down
57 changes: 57 additions & 0 deletions scripts/linux/tests/test_security_packaging.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 17 additions & 3 deletions src/Files.Platform.Linux/Archives/ArchiveSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ namespace Files.Platform.Linux.Archives
/// <summary>
/// An entry as read from an archive. Names, sizes and link targets are untrusted.
/// </summary>
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);

/// <summary>
/// A readable archive, hiding which SharpCompress API (archive, tar reader, single compressed file) is behind it.
Expand Down Expand Up @@ -128,6 +128,14 @@ public ZipLikeSource(IArchive archive, Stream input)

public override IEnumerable<(EntryData Entry, Func<Stream> 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();
Expand Down Expand Up @@ -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<Stream> 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)
Expand All @@ -196,7 +210,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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/Files.Platform.Linux/Native/PosixFileOperationsNative.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ 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();
return result == 0;
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;
}

public static void RenameAt(int sourceFd, string source, int destinationFd, string destination, bool replace, string displayPath, LinuxFileOperationsHooks? hooks = null)
Expand Down
Loading
Loading