fix(release): verify AppImage permissions and inputs

- Inspect launcher permissions from stored SquashFS metadata.
- Pin and checksum the runtime embedded during AppImage packaging.
- Cover metadata validation and release tool inputs with regression tests.
This commit is contained in:
NimBold
2026-10-09 23:48:54 +03:30
parent 63bcbd5eeb
commit eea313b45f
5 changed files with 147 additions and 3 deletions
+10
View File
@@ -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: |
+72
View File
@@ -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;
}
@@ -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`;
+7
View File
@@ -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/);
+13 -3
View File
@@ -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');