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
2 changes: 1 addition & 1 deletion .github/workflows/flatpak-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@
<a href="README_ja.md">日本語</a>
</p>

<p align="center">
Listed by the PostgreSQL project:
<a href="https://www.postgresql.org/about/news/libredb-studio-an-open-source-self-hosted-sql-ide-for-postgresql-in-the-browser-3368/">News</a>
·
<a href="https://www.postgresql.org/download/products/1/">Software Catalogue</a>
·
<a href="https://wiki.postgresql.org/wiki/Community_Guide_to_PostgreSQL_GUI_Tools#LibreDB_Studio">Community Guide to GUI Tools</a>
</p>
<p align="center">
Also listed in official
<a href="https://redis.io/docs/latest/develop/tools/#libredb-studio">Redis</a>,
<a href="https://clickhouse.com/docs/integrations/connectors/tools/gui#libredb-studio">ClickHouse</a>
and
<a href="https://druid.apache.org/libraries">Apache Druid</a>
docs
</p>

<p align="center">
<img src="public/screenshots/hero-demo.gif" alt="Opening a table, running a join, charting the result and reading the ER diagram in LibreDB Studio" width="100%" />
</p>
Expand Down
15 changes: 15 additions & 0 deletions README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
<b>日本語</b>
</p>

<p align="center">
PostgreSQL プロジェクトに掲載:
<a href="https://www.postgresql.org/about/news/libredb-studio-an-open-source-self-hosted-sql-ide-for-postgresql-in-the-browser-3368/">News</a>
·
<a href="https://www.postgresql.org/download/products/1/">Software Catalogue</a>
·
<a href="https://wiki.postgresql.org/wiki/Community_Guide_to_PostgreSQL_GUI_Tools#LibreDB_Studio">Community Guide to GUI Tools</a>
</p>
<p align="center">
<a href="https://redis.io/docs/latest/develop/tools/#libredb-studio">Redis</a>、
<a href="https://clickhouse.com/docs/integrations/connectors/tools/gui#libredb-studio">ClickHouse</a>、
<a href="https://druid.apache.org/libraries">Apache Druid</a>
の公式ドキュメントにも掲載
</p>

<p align="center">
<img src="public/screenshots/hero-demo.gif" alt="LibreDB Studio" width="100%" />
</p>
Expand Down
17 changes: 17 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@
<a href="README_ja.md">日本語</a>
</p>

<p align="center">
已列入 PostgreSQL 项目:
<a href="https://www.postgresql.org/about/news/libredb-studio-an-open-source-self-hosted-sql-ide-for-postgresql-in-the-browser-3368/">News</a>
·
<a href="https://www.postgresql.org/download/products/1/">Software Catalogue</a>
·
<a href="https://wiki.postgresql.org/wiki/Community_Guide_to_PostgreSQL_GUI_Tools#LibreDB_Studio">Community Guide to GUI Tools</a>
</p>
<p align="center">
同时列入
<a href="https://redis.io/docs/latest/develop/tools/#libredb-studio">Redis</a>、
<a href="https://clickhouse.com/docs/integrations/connectors/tools/gui#libredb-studio">ClickHouse</a>
<a href="https://druid.apache.org/libraries">Apache Druid</a>
官方文档
</p>

<p align="center">
<img src="public/screenshots/hero-demo.gif" alt="LibreDB Studio" width="100%" />
</p>
Expand Down
19 changes: 18 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions scripts/build-desktop-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
Expand Down
95 changes: 95 additions & 0 deletions scripts/check-appimage-perms.mjs
Original file line number Diff line number Diff line change
@@ -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 <extracted-appdir>
*
* 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 <extracted-appdir>");
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));
}
92 changes: 92 additions & 0 deletions tests/unit/check-appimage-perms.test.ts
Original file line number Diff line number Diff line change
@@ -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, number>): 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("");
});
});
Loading
Loading