diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47a94cd..bbb44b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,7 @@ jobs: rpm \ cpio \ libarchive-tools \ + squashfs-tools \ desktop-file-utils \ xdg-utils - run: npm ci @@ -190,13 +191,22 @@ jobs: # continuous currently targets 854e19e951900cf141245b384d45735942521abe. APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage APPIMAGETOOL_SHA256: 95cbe7cce9717fce90c484e34052ee7c7f1d7635b33c12525b4776826a7d29b6 + # appimagetool otherwise downloads this mutable runtime during packaging. + # type2-runtime continuous currently targets 8f39b89e2ac31e1640b3d3f7e9a5108e6ce805fa. + APPIMAGETOOL_RUNTIME_URL: https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64 + APPIMAGETOOL_RUNTIME_SHA256: 156f4bdbde9c52d01814600013e0a273f0118dc2de98975f3c8c63427ec79074 run: | curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool" echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c - chmod +x "$RUNNER_TEMP/appimagetool" + curl -fsSL "$APPIMAGETOOL_RUNTIME_URL" -o "$RUNNER_TEMP/appimage-runtime" + echo "$APPIMAGETOOL_RUNTIME_SHA256 $RUNNER_TEMP/appimage-runtime" | sha256sum -c - + chmod +x "$RUNNER_TEMP/appimage-runtime" - name: Repack AppImage with engines (Linux only) if: runner.os == 'Linux' run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool" + env: + APPIMAGETOOL_RUNTIME_FILE: ${{ runner.temp }}/appimage-runtime - name: Verify macOS packaged engines and launch if: runner.os == 'macOS' run: | diff --git a/scripts/appimage-launcher-permissions.js b/scripts/appimage-launcher-permissions.js index 3238f9d..fe4c50e 100644 --- a/scripts/appimage-launcher-permissions.js +++ b/scripts/appimage-launcher-permissions.js @@ -112,3 +112,75 @@ export function normalizeAppRunPermissions(root, label = 'AppDir') { export function verifyAppRunPermissions(root, label = 'AppImage') { return checkAppRunPermissions(root, label, false); } + +function squashfsEntryMode(listing, relativePath, label, expectedType) { + const matchingLines = listing + .split(/\r?\n/) + .filter(line => { + const columns = line.trim().split(/\s+/); + return columns.at(-1)?.replace(/\/$/, '') === relativePath; + }); + + if (matchingLines.length !== 1) { + throw new Error(`${label} ${relativePath} must appear exactly once in the SquashFS permission listing.`); + } + + const mode = /^([bcdlps-][rwxStTs-]{9})\s/.exec(matchingLines[0])?.[1]; + if (!mode) { + throw new Error(`${label} ${relativePath} has an invalid SquashFS permission record.`); + } + + if (mode[0] !== expectedType) { + throw new Error(`${label} ${relativePath} has the wrong SquashFS file type.`); + } + + return mode.slice(1); +} + +function assertListedOtherReadableExecutable(mode, label) { + if (mode[6] !== 'r' || !['x', 't'].includes(mode[8])) { + throw new Error(`${label} must be readable and executable by other users in the SquashFS image; found mode ${mode}.`); + } +} + +function assertListedOtherDirectoryAccess(mode, label) { + if (mode[6] !== 'r' || !['x', 't'].includes(mode[8])) { + throw new Error(`${label} must be readable and traversable by other users in the SquashFS image; found mode ${mode}.`); + } +} + +function assertListedOtherReadable(mode, label) { + if (mode[6] !== 'r') { + throw new Error(`${label} must be readable by other users in the SquashFS image; found mode ${mode}.`); + } +} + +export function verifySquashfsAppRunPermissions(listing, label = 'AppImage') { + const rootMode = squashfsEntryMode(listing, 'squashfs-root', `${label} AppDir root`, 'd'); + const appRunMode = squashfsEntryMode(listing, 'squashfs-root/AppRun', `${label} AppRun`, '-'); + const wrappedMode = squashfsEntryMode( + listing, + 'squashfs-root/AppRun.wrapped', + `${label} AppRun.wrapped launcher`, + '-', + ); + const hookDirectoryMode = squashfsEntryMode( + listing, + 'squashfs-root/apprun-hooks', + `${label} AppRun hook directory`, + 'd', + ); + const gtkHookMode = squashfsEntryMode( + listing, + 'squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh', + `${label} GTK AppRun hook`, + '-', + ); + + assertListedOtherDirectoryAccess(rootMode, `${label} AppDir root`); + assertListedOtherReadableExecutable(appRunMode, `${label} AppRun`); + assertListedOtherReadableExecutable(wrappedMode, `${label} AppRun.wrapped launcher`); + assertListedOtherDirectoryAccess(hookDirectoryMode, `${label} AppRun hook directory`); + assertListedOtherReadable(gtkHookMode, `${label} GTK AppRun hook`); + return true; +} diff --git a/scripts/appimage-launcher-permissions.node-test.js b/scripts/appimage-launcher-permissions.node-test.js index 2b24654..1c9ceac 100644 --- a/scripts/appimage-launcher-permissions.node-test.js +++ b/scripts/appimage-launcher-permissions.node-test.js @@ -6,6 +6,7 @@ import { test } from 'node:test'; import { normalizeAppRunPermissions, verifyAppRunPermissions, + verifySquashfsAppRunPermissions, } from './appimage-launcher-permissions.js'; function createAppDir() { @@ -108,6 +109,50 @@ test('post-pack verification rejects an inaccessible AppDir root and AppRun hook } }); +function squashfsListing(overrides = {}) { + const entries = { + 'squashfs-root': 'drwxr-xr-x', + 'squashfs-root/AppRun': '-rwxr-xr-x', + 'squashfs-root/AppRun.wrapped': '-rwxr-xr-x', + 'squashfs-root/apprun-hooks': 'drwxr-xr-x', + 'squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh': '-rw-r--r--', + ...overrides, + }; + + return Object.entries(entries) + .filter(([, mode]) => mode !== null) + .map(([entry, mode]) => `${mode} 0/0 100 1970-01-01 00:00 ${entry}`) + .join('\n'); +} + +test('checks launcher permissions from stored SquashFS metadata', () => { + assert.equal(verifySquashfsAppRunPermissions(squashfsListing()), true); +}); + +test('rejects inaccessible stored directories and launch files', () => { + assert.throws( + () => verifySquashfsAppRunPermissions(squashfsListing({ 'squashfs-root': 'drwx------' })), + /AppDir root.*found mode rwx------/, + ); + assert.throws( + () => verifySquashfsAppRunPermissions(squashfsListing({ 'squashfs-root/apprun-hooks': 'drwx------' })), + /hook directory.*found mode rwx------/, + ); + assert.throws( + () => verifySquashfsAppRunPermissions(squashfsListing({ 'squashfs-root/AppRun.wrapped': '-rwxr-----' })), + /AppRun\.wrapped.*found mode rwxr-----/, + ); +}); + +test('fails closed for missing or non-regular SquashFS launcher entries', () => { + const listing = squashfsListing({ 'squashfs-root/AppRun.wrapped': 'lrwxrwxrwx' }); + assert.throws(() => verifySquashfsAppRunPermissions(listing), /AppRun\.wrapped.*wrong SquashFS file type/); + assert.throws( + () => verifySquashfsAppRunPermissions(squashfsListing({ 'squashfs-root/AppRun': null })), + /AppRun .*must appear exactly once/, + ); +}); + test('rejects an AppRun.wrapped symlink instead of chmodding its target', { skip: process.platform === 'win32' }, () => { const root = createAppDir(); const outside = `${root}-outside`; diff --git a/scripts/release-workflow.node-test.js b/scripts/release-workflow.node-test.js index 9c20cf4..3c49238 100644 --- a/scripts/release-workflow.node-test.js +++ b/scripts/release-workflow.node-test.js @@ -33,6 +33,13 @@ test('release workflow normalizes all 6 distribution target artifacts', () => { assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/); }); +test('Linux release pins and verifies appimagetool and its runtime inputs', () => { + assert.match(releaseWorkflow, /APPIMAGETOOL_SHA256: 95cbe7cce9717fce90c484e34052ee7c7f1d7635b33c12525b4776826a7d29b6/); + assert.match(releaseWorkflow, /APPIMAGETOOL_RUNTIME_SHA256: 156f4bdbde9c52d01814600013e0a273f0118dc2de98975f3c8c63427ec79074/); + assert.match(releaseWorkflow, /echo "\$APPIMAGETOOL_RUNTIME_SHA256 \$RUNNER_TEMP\/appimage-runtime" \| sha256sum -c -/); + assert.match(releaseWorkflow, /APPIMAGETOOL_RUNTIME_FILE: \$\{\{ runner\.temp \}\}\/appimage-runtime/); +}); + test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => { assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/); assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/); diff --git a/scripts/repack-linux-appimage-engines.js b/scripts/repack-linux-appimage-engines.js index f1981a1..a59e916 100755 --- a/scripts/repack-linux-appimage-engines.js +++ b/scripts/repack-linux-appimage-engines.js @@ -5,7 +5,10 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { collectRegularFiles, sha256 } from './engine-payload-integrity.js'; -import { normalizeAppRunPermissions, verifyAppRunPermissions } from './appimage-launcher-permissions.js'; +import { + normalizeAppRunPermissions, + verifySquashfsAppRunPermissions, +} from './appimage-launcher-permissions.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -83,6 +86,7 @@ function run(command, args, options = {}) { env: { ...process.env, ...options.env }, stdio: options.stdio ?? 'inherit', encoding: options.stdio === 'pipe' ? 'utf8' : undefined, + maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, }); if (result.error) { @@ -100,6 +104,8 @@ function run(command, args, options = {}) { } fail(`${command} exited with status ${result.status}`); } + + return options.stdio === 'pipe' ? result.stdout : undefined; } function main() { @@ -147,6 +153,12 @@ function main() { }, }); + // The AppImage runtime's --appimage-extract creates directories as 0700 and + // does not restore their SquashFS modes. Inspect the image metadata directly + // so this check reflects the mounted AppImage rather than extraction policy. + const squashfsListing = run('unsquashfs', ['-lln', appImage], { stdio: 'pipe' }); + verifySquashfsAppRunPermissions(squashfsListing, 'Repacked AppImage'); + const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-appimage-')); try { fs.chmodSync(appImage, 0o755); @@ -157,8 +169,6 @@ function main() { }); const squashfsRoot = path.join(extractRoot, 'squashfs-root'); - verifyAppRunPermissions(squashfsRoot, 'Repacked AppImage'); - const extractedPayload = path.join(squashfsRoot, 'usr', 'lib', 'Firelink', 'engine-dist', target); mustBeDirectory(extractedPayload, 'Extracted AppImage engine payload'); validatePayloadManifest(extractedPayload, target, 'Extracted AppImage engine');