From 9a8cf43ec79f6431d1ad20bef8c5c08be3f2f3ce Mon Sep 17 00:00:00 2001
From: cevheri
Date: Mon, 24 Aug 2026 01:16:10 +0300
Subject: [PATCH 1/2] docs(readme): update README files to include listings in
PostgreSQL, Redis, ClickHouse, and Apache Druid official documentation
---
README.md | 17 +++++++++++++++++
README_ja.md | 15 +++++++++++++++
README_zh.md | 17 +++++++++++++++++
3 files changed, 49 insertions(+)
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
+ 官方文档
+
+
From 8a200eae270f386563eb3f4f03e1c2316af5b319 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Mon, 24 Aug 2026 01:42:43 +0300
Subject: [PATCH 2/2] fix(desktop): the AppImage shipped a launcher only its
builder could execute
linuxdeploy writes `AppRun.wrapped` into the AppDir as `0770 root:root`, and the
released image carried that mode. Measured on 0.13.2: `-rwxrwx--- root/root
AppRun.wrapped`, reproduced as uid 1000 over root-owned files as
`/AppRun: line 12: /AppRun.wrapped: Permission denied`, and fixed by that one
file at 0755.
The AppImage runtime hides this from the case everyone tests. A type-2 image
mounts its squashfs through FUSE privately to the invoking user, and a private
FUSE mount skips the kernel permission check - measured too: the shipped 0.13.2
image mounts and opens its window as uid 1000 exactly as it always did. What
breaks is every reader that applies real permission checks: an extracted AppDir
owned by another uid, a container running as non-root, and firejail's
`--appimage` mount, which is what AppImageHub's review CI runs. There the app
never starts, with a bare `Permission denied` and no window to diagnose from.
So the build now audits the extracted modes and repacks 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
image mounts on the user's machine at all, and swapping it to fix a file mode
would trade this defect for a worse one. Verified end to end on Ubuntu 22.04 - the
repacked image mounts and opens its window as uid 1000, and its AppDir also runs
when the files are root-owned, which the shipped one does not.
The audit is `scripts/check-appimage-perms.mjs` so it is unit tested rather than
asserted in bash, and both AppImage builds now install squashfs-tools.
---
.github/workflows/flatpak-smoke.yml | 2 +-
.github/workflows/release-artifacts.yml | 6 +-
desktop/README.md | 19 +++-
scripts/build-desktop-appimage.sh | 49 ++++++++++
scripts/check-appimage-perms.mjs | 95 +++++++++++++++++++
tests/unit/check-appimage-perms.test.ts | 92 ++++++++++++++++++
.../unit/desktop-appimage-portability.test.ts | 18 +++-
7 files changed, 275 insertions(+), 6 deletions(-)
create mode 100644 scripts/check-appimage-perms.mjs
create mode 100644 tests/unit/check-appimage-perms.test.ts
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/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");
+ }
+ });
});