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
+
+
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
+ の公式ドキュメントにも掲載
+
+
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
+ 官方文档
+
+
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");
+ }
+ });
});