diff --git a/.github/workflows/flatpak-smoke.yml b/.github/workflows/flatpak-smoke.yml index 0aac2cf5..40d06378 100644 --- a/.github/workflows/flatpak-smoke.yml +++ b/.github/workflows/flatpak-smoke.yml @@ -93,7 +93,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file squashfs-tools - name: Build the AppImage and smoke test the bundled server # --keep-stage leaves the payload and the Node sidecar in place, which the diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 704fe6a9..6df7b534 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -912,11 +912,13 @@ jobs: - name: Install Tauri Linux build dependencies # patchelf is required by linuxdeploy (the AppImage bundler Tauri drives) - # and is NOT preinstalled on the runner images. + # and is NOT preinstalled on the runner images. squashfs-tools is what + # repacks the image when the bundler leaves a file mode only its builder + # can use - see the permission audit in scripts/build-desktop-appimage.sh. run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file squashfs-tools - name: Download linux tarball uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/README.md b/README.md index 364737a0..4a59218a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,23 @@ 日本語

+

+ Listed by the PostgreSQL project: + News + · + Software Catalogue + · + Community Guide to GUI Tools +

+

+ Also listed in official + Redis, + ClickHouse + and + Apache Druid + docs +

+

Opening a table, running a join, charting the result and reading the ER diagram in LibreDB Studio

diff --git a/README_ja.md b/README_ja.md index aefd8da9..c3c8fdac 100644 --- a/README_ja.md +++ b/README_ja.md @@ -14,6 +14,21 @@ 日本語

+

+ PostgreSQL プロジェクトに掲載: + News + · + Software Catalogue + · + Community Guide to GUI Tools +

+

+ Redis、 + ClickHouse、 + Apache Druid + の公式ドキュメントにも掲載 +

+

LibreDB Studio

diff --git a/README_zh.md b/README_zh.md index c7c8e7b5..b7f73a7f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -14,6 +14,23 @@ 日本語

+

+ 已列入 PostgreSQL 项目: + News + · + Software Catalogue + · + Community Guide to GUI Tools +

+

+ 同时列入 + Redis、 + ClickHouse + 和 + Apache Druid + 官方文档 +

+

LibreDB Studio

diff --git a/desktop/README.md b/desktop/README.md index 410e39d5..9483c114 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -82,7 +82,7 @@ Notes on the choices, since they are easy to get wrong later: ```bash # One-time system dependencies (Debian/Ubuntu names) -sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file +sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf file squashfs-tools # Stage the payload + node sidecar, then bundle an AppImage (keeps the staging # directories so `tauri dev` works straight after) @@ -108,6 +108,23 @@ cd desktop/src-tauri && cargo test `.github/workflows/release-artifacts.yml`; both artifacts are required release assets. `--deb-only` skips the AppImage, which is the way to build on a host without the linuxdeploy GTK toolchain (`librsvg2-dev` and friends). +- **The bundler leaves one file unusable to anyone but its builder, and the + build repacks the image to fix it.** linuxdeploy writes `AppRun.wrapped` into + the AppDir as `0770 root:root`. The AppImage runtime hides that from the only + case anyone tests: the squashfs is mounted through FUSE privately to the + invoking user, and a private FUSE mount skips the kernel permission check, so + double-clicking works whatever the recorded mode says. Read the same bytes + WITH permission checks - an extracted AppDir owned by another uid, a container + running as non-root, or firejail's `--appimage` mount, which is what + AppImageHub's review CI uses - and `AppRun` cannot exec `AppRun.wrapped`: a + bare `Permission denied`, no window, nothing to diagnose from. So + `scripts/build-desktop-appimage.sh` audits the extracted modes with + `scripts/check-appimage-perms.mjs` and, when the audit fails, widens the modes + and repacks with `mksquashfs` (hence `squashfs-tools`). The repack reuses the + **original runtime bytes and squashfs parameters** rather than calling + appimagetool again: the runtime decides whether the image mounts on the user's + machine at all, so swapping it to fix a file mode would trade this defect for a + worse one. - **The x64 AppImage is built on the oldest still-supported Ubuntu LTS, and that is load-bearing.** An AppImage inherits the glibc of its build machine, so the runner label sets the floor for every user: built on 24.04 the bundled diff --git a/scripts/build-desktop-appimage.sh b/scripts/build-desktop-appimage.sh index 317d05fd..b7d4464a 100755 --- a/scripts/build-desktop-appimage.sh +++ b/scripts/build-desktop-appimage.sh @@ -279,6 +279,55 @@ if [ "$DEB_ONLY" != "true" ]; then exit 1 fi + # ---------------------------------------------------------------------------- + # linuxdeploy writes the wrapped launcher into the AppDir as 0770 root:root, + # and the AppImage runtime hides that from the only case anyone tests: the + # squashfs is mounted through FUSE privately to the invoking user, and a + # private FUSE mount skips the kernel permission check, so double-clicking + # works whatever the recorded mode says. Read the same bytes WITH permission + # checks - an extracted AppDir owned by another uid, a container running as + # non-root, or firejail's --appimage mount, which is what AppImageHub's review + # CI uses - and AppRun cannot exec AppRun.wrapped. The app dies with a bare + # "Permission denied" and never opens a window. + # + # So the modes are audited here and the image is repacked when the audit + # fails. The repack reuses the ORIGINAL runtime bytes and the original + # squashfs parameters rather than calling appimagetool again: the runtime is + # what decides whether the AppImage mounts on the user's machine at all, and + # swapping it for a different build to fix a file mode would trade this defect + # for a worse one. + # ---------------------------------------------------------------------------- + PERM_DIR="$WORK_DIR/appimage-perms" + mkdir -p "$PERM_DIR" + (cd "$PERM_DIR" && "$BUILT" --appimage-extract > /dev/null) + if node "$ROOT_DIR/scripts/check-appimage-perms.mjs" "$PERM_DIR/squashfs-root"; then + echo "==> Permissions: the bundler left every file world-readable, no repack needed" + else + echo "==> Permissions: repacking the AppImage with the offending modes widened" + find "$PERM_DIR/squashfs-root" -type f -perm -u+x ! -perm -o+x -exec chmod go+rx {} + + find "$PERM_DIR/squashfs-root" -type f ! -perm -o+r -exec chmod go+r {} + + OFFSET=$("$BUILT" --appimage-offset) + head -c "$OFFSET" "$BUILT" > "$PERM_DIR/runtime.bin" + tail -c "+$((OFFSET + 1))" "$BUILT" > "$PERM_DIR/original.sqfs" + # Match the bundler's own squashfs parameters instead of hardcoding them, so + # a future appimagetool that changes compression does not silently produce a + # differently-packed image here. + SQFS_COMP=$(unsquashfs -s "$PERM_DIR/original.sqfs" | awk '/^Compression/ { print $2 }') + SQFS_BLOCK=$(unsquashfs -s "$PERM_DIR/original.sqfs" | awk '/^Block size/ { print $3 }') + echo "==> Permissions: repacking with -comp $SQFS_COMP -b $SQFS_BLOCK" + mksquashfs "$PERM_DIR/squashfs-root" "$PERM_DIR/repacked.sqfs" \ + -comp "$SQFS_COMP" -b "$SQFS_BLOCK" -root-owned -noappend -no-progress -quiet + cat "$PERM_DIR/runtime.bin" "$PERM_DIR/repacked.sqfs" > "$PERM_DIR/repacked.AppImage" + chmod +x "$PERM_DIR/repacked.AppImage" + # Re-extract the repacked image and audit that, not the tree we chmodded: + # the artifact is what ships, and a repack that dropped the modes would + # otherwise pass on the strength of the input. + rm -rf "$PERM_DIR/verify" && mkdir -p "$PERM_DIR/verify" + (cd "$PERM_DIR/verify" && "$PERM_DIR/repacked.AppImage" --appimage-extract > /dev/null) + node "$ROOT_DIR/scripts/check-appimage-perms.mjs" "$PERM_DIR/verify/squashfs-root" + BUILT="$PERM_DIR/repacked.AppImage" + fi + install -m 0755 "$BUILT" "$OUT_DIR/$ASSET" (cd "$OUT_DIR" && sha256sum "$ASSET" > "${ASSET}.sha256") echo "==> Wrote $OUT_DIR/$ASSET ($(du -h "$OUT_DIR/$ASSET" | cut -f1))" diff --git a/scripts/check-appimage-perms.mjs b/scripts/check-appimage-perms.mjs new file mode 100644 index 00000000..ea77878e --- /dev/null +++ b/scripts/check-appimage-perms.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Audit an extracted AppImage AppDir for file modes that only its builder can use. + * + * linuxdeploy writes the wrapped launcher as `0770 root:root`, and the AppImage + * runtime hides that from the only case anyone tests: a type-2 AppImage mounts + * its squashfs through FUSE privately to the invoking user, and a private FUSE + * mount skips the kernel permission check, so double-clicking works whatever the + * recorded mode says. Read the same bytes with real permission checks - an + * extracted AppDir owned by another uid, a container running as non-root, or + * firejail's `--appimage` mount, which is what AppImageHub's review CI uses - + * and `AppRun` cannot exec `AppRun.wrapped`: the app dies with a bare + * `Permission denied` and never opens a window. + * + * So the artifact needs a gate no user-facing symptom would give us in time. + * `scripts/build-desktop-appimage.sh` runs this over the extracted AppDir, and + * repacks the image with the offending modes widened when it fails. + * + * Usage: + * node scripts/check-appimage-perms.mjs + * + * Exit 0 when every regular file is world-readable, and world-executable + * wherever its owner can execute it. Exit 1 listing the offenders otherwise. + */ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +/** + * Walk `root` and return the regular files whose mode would deny a user who is + * not the owner. Symlinks are skipped: their own mode is 0777 on Linux and says + * nothing, and their target is walked on its own. + * + * @param {string} root + * @returns {{path: string, mode: string, reason: string}[]} offenders, sorted by path + */ +export function auditAppDirPermissions(root) { + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + throw new Error(`check-appimage-perms: ${root} is not a directory`); + } + const offenders = []; + /** @param {string} dir */ + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const full = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.isFile()) continue; + const mode = fs.statSync(full).mode & 0o7777; + const rel = path.relative(root, full); + const printed = `0${(mode & 0o777).toString(8)}`; + // Executability is reported first on purpose: 0770 fails both checks, and + // the failure a user actually hits is the exec of AppRun.wrapped. + if ((mode & 0o100) !== 0 && (mode & 0o001) === 0) { + offenders.push({ path: rel, mode: printed, reason: "not world-executable" }); + } else if ((mode & 0o004) === 0) { + offenders.push({ path: rel, mode: printed, reason: "not world-readable" }); + } + } + }; + walk(root); + return offenders.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * @param {{path: string, mode: string, reason: string}[]} offenders + * @returns {string} one indented line per offender, empty for a clean audit + */ +export function formatOffenders(offenders) { + return offenders.map((o) => ` ${o.mode} ${o.path} (${o.reason})`).join("\n"); +} + +/** @param {string[]} argv */ +function main(argv) { + const [root] = argv; + if (!root) { + console.error("Usage: node scripts/check-appimage-perms.mjs "); + process.exit(2); + } + const offenders = auditAppDirPermissions(root); + if (offenders.length > 0) { + console.error(`check-appimage-perms: ${offenders.length} file(s) are unusable for anyone but the owner:`); + console.error(formatOffenders(offenders)); + process.exit(1); + } + console.log("check-appimage-perms: OK - every bundled file is world-readable"); +} + +// CLI entry only when executed directly (the unit test imports this module). +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/tests/unit/check-appimage-perms.test.ts b/tests/unit/check-appimage-perms.test.ts new file mode 100644 index 00000000..fc0c33bf --- /dev/null +++ b/tests/unit/check-appimage-perms.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for the AppImage permission audit (scripts/check-appimage-perms.mjs). + * + * Why this exists at all: linuxdeploy writes the wrapped launcher into the + * AppDir as `0770 root:root`, and the AppImage runtime hides that from the one + * case everybody tests. A type-2 AppImage mounts its squashfs through FUSE + * privately to the invoking user, and a private FUSE mount skips the kernel's + * permission check - so double-clicking works no matter what the recorded mode + * says. The moment the same bytes are read with real permission checks (an + * extracted AppDir owned by another uid, a container running as non-root, or + * firejail's `--appimage` mount, which is what AppImageHub's review CI uses) + * `AppRun` cannot exec `AppRun.wrapped` and the app dies with a bare + * `Permission denied`. + * + * Measured on the released 0.13.2 artifact: `-rwxrwx--- root/root + * AppRun.wrapped`, reproduced as uid 1000 over root-owned files, and fixed by + * the same file at 0755. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { auditAppDirPermissions, formatOffenders } from "../../scripts/check-appimage-perms.mjs"; + +/** Build a throwaway AppDir. `entries` maps a relative path to its octal mode. */ +const appDir = (entries: Record): string => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appdir-perms-")); + for (const [rel, mode] of Object.entries(entries)) { + const target = path.join(root, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, "x"); + fs.chmodSync(target, mode); + } + return root; +}; + +describe("auditAppDirPermissions", () => { + test("passes an AppDir whose files are all world-readable", () => { + const root = appDir({ AppRun: 0o755, "usr/bin/app": 0o755, "usr/share/icon.png": 0o644 }); + expect(auditAppDirPermissions(root)).toEqual([]); + }); + + test("reports an owner-executable file that is not world-executable", () => { + // The exact shape of the defect: 0770 executes for the builder's uid and + // nobody else. + const root = appDir({ AppRun: 0o755, "AppRun.wrapped": 0o770 }); + const offenders = auditAppDirPermissions(root); + expect(offenders).toEqual([{ path: "AppRun.wrapped", mode: "0770", reason: "not world-executable" }]); + }); + + test("reports a plain file that is not world-readable", () => { + const root = appDir({ "usr/share/data.json": 0o640 }); + expect(auditAppDirPermissions(root)).toEqual([ + { path: "usr/share/data.json", mode: "0640", reason: "not world-readable" }, + ]); + }); + + test("walks nested directories and returns offenders in a stable order", () => { + const root = appDir({ + "usr/lib/b.so": 0o640, + "usr/bin/a": 0o770, + AppRun: 0o755, + }); + expect(auditAppDirPermissions(root).map((o) => o.path)).toEqual(["usr/bin/a", "usr/lib/b.so"]); + }); + + test("ignores a symlink rather than reporting the mode of its own inode", () => { + // .DirIcon is a symlink in every AppImage this repo builds, and a symlink's + // own mode is 0777 on Linux and meaningless - what matters is its target, + // which is walked on its own. + const root = appDir({ "usr/share/icons/hicolor/32x32/apps/app.png": 0o644 }); + fs.symlinkSync("usr/share/icons/hicolor/32x32/apps/app.png", path.join(root, ".DirIcon")); + expect(auditAppDirPermissions(root)).toEqual([]); + }); + + test("throws when the directory does not exist, rather than reporting a clean audit", () => { + // A silent pass on a mistyped path would turn this gate into decoration. + expect(() => auditAppDirPermissions(path.join(os.tmpdir(), "appdir-perms-absent-xyz"))).toThrow(/not a directory/); + }); +}); + +describe("formatOffenders", () => { + test("renders one line per offender with its mode and reason", () => { + expect(formatOffenders([{ path: "AppRun.wrapped", mode: "0770", reason: "not world-executable" }])).toBe( + " 0770 AppRun.wrapped (not world-executable)", + ); + }); + + test("renders an empty string for a clean audit", () => { + expect(formatOffenders([])).toBe(""); + }); +}); diff --git a/tests/unit/desktop-appimage-portability.test.ts b/tests/unit/desktop-appimage-portability.test.ts index 04435049..97050cce 100644 --- a/tests/unit/desktop-appimage-portability.test.ts +++ b/tests/unit/desktop-appimage-portability.test.ts @@ -26,6 +26,7 @@ interface MatrixEntry { } interface Job { strategy?: { matrix?: { include?: MatrixEntry[] } }; + steps?: { name?: string; run?: string }[]; } const workflow = (name: string): { jobs: Record } => @@ -33,9 +34,13 @@ const workflow = (name: string): { jobs: Record } => jobs: Record; }; -const desktopMatrix = workflow("release-artifacts.yml").jobs["desktop-appimage"]?.strategy?.matrix?.include ?? []; -const smokeMatrix = workflow("flatpak-smoke.yml").jobs.appimage?.strategy?.matrix?.include ?? []; +const desktopJob = workflow("release-artifacts.yml").jobs["desktop-appimage"]; +const smokeJob = workflow("flatpak-smoke.yml").jobs.appimage; +const desktopMatrix = desktopJob?.strategy?.matrix?.include ?? []; +const smokeMatrix = smokeJob?.strategy?.matrix?.include ?? []; const x64Runner = (matrix: MatrixEntry[]): string | undefined => matrix.find((entry) => entry.arch === "x64")?.runner; +const aptStep = (job: Job | undefined): string => + job?.steps?.find((step) => step.run?.includes("apt-get install"))?.run ?? ""; describe("release-artifacts.yml desktop-appimage glibc floor", () => { test("builds every architecture on a pinned runner image, never a floating label", () => { @@ -62,4 +67,13 @@ describe("release-artifacts.yml desktop-appimage glibc floor", () => { expect(smokeMatrix.length).toBeGreaterThan(0); expect(x64Runner(smokeMatrix)).toBe(x64Runner(desktopMatrix)); }); + + test("both AppImage builds install the tool the permission repack needs", () => { + // Without squashfs-tools the repack in scripts/build-desktop-appimage.sh + // cannot run, and the job fails on the release commit rather than on a PR. + // Neither runner image preinstalls it. + for (const run of [aptStep(desktopJob), aptStep(smokeJob)]) { + expect(run).toContain("squashfs-tools"); + } + }); });