// SPDX-License-Identifier: AGPL-3.0-or-later import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import {createServer} from 'node:http'; import {createRequire} from 'node:module'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {after, before, describe, test} from 'node:test'; import {fileURLToPath} from 'node:url'; import vm from 'node:vm'; const require = createRequire(import.meta.url); const esbuild = require('esbuild'); const sourcePath = fileURLToPath(new URL('./AppImageUpdate.ts', import.meta.url)); const source = readFileSync(sourcePath, 'utf8'); const transformedSource = esbuild.transformSync(source, { loader: 'ts', format: 'cjs', platform: 'node', target: 'node20', }).code; function loadAppImageUpdate(env = {}, overrides = {}, requireImpl = require) { const module = {exports: {}}; const context = vm.createContext({ module, exports: module.exports, require: requireImpl, console, Buffer, AbortController, setTimeout, clearTimeout, fetch, Error, Number, process: {...process, ...overrides, env: {...env}}, }); vm.runInContext(transformedSource, context, {filename: sourcePath}); return module.exports; } const INSTALLED_NAME = 'My Fluxer.AppImage'; const OLD_BYTES = Buffer.from('old-appimage-payload'); const NEW_BYTES = Buffer.alloc(400_000, 7); const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex'); let server; let baseUrl; let requestCount = 0; before(async () => { server = createServer((request, response) => { requestCount += 1; if (request.url === '/appimage') { response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length}); response.end(NEW_BYTES); return; } if (request.url === '/corrupt') { const corrupt = Buffer.from(NEW_BYTES); corrupt[0] = corrupt[0] ^ 0xff; response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': corrupt.length}); response.end(corrupt); return; } response.writeHead(404); response.end('missing'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(async () => { await new Promise((resolve) => server.close(resolve)); }); function createInstall() { const root = mkdtempSync(join(tmpdir(), 'fluxer-appimage-test-')); const applications = join(root, 'Applications'); mkdirSync(applications); const installedPath = join(applications, INSTALLED_NAME); writeFileSync(installedPath, OLD_BYTES, {mode: 0o755}); return {root, applications, installedPath}; } function stagingLeftovers(directory) { return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-')); } function assertUnavailable(result, reason) { assert.equal(result.ok, false); assert.equal(result.reason, reason); } function createAppDir(name) { const root = mkdtempSync(join(tmpdir(), 'fluxer-appdir-test-')); const dir = join(root, name); mkdirSync(dir); writeFileSync(join(dir, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); const binary = join(dir, 'fluxer-canary'); writeFileSync(binary, 'elf', {mode: 0o755}); return {dir, binary}; } function stagingPrefixFor(installedPath) { return `.fluxer-update-${createHash('sha256').update(installedPath).digest('hex').slice(0, 8)}-`; } describe('AppImageUpdate process provenance', () => { test('accepts the process the AppImage runtime execed out of its own mount', () => { const {isRunningFromAppImage} = loadAppImageUpdate({}); const mounted = createAppDir('.mount_My FluAKcenK'); assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, mounted.binary), true); assert.equal(isRunningFromAppImage({APPDIR: `${mounted.dir}/`}, mounted.binary), true); assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, join(mounted.dir, 'usr', 'bin', 'fluxer-canary')), true); }); test('refuses an extracted AppDir, whose APPIMAGE belongs to whatever launched it', () => { const {isRunningFromAppImage} = loadAppImageUpdate({}); const extracted = createAppDir('squashfs-root'); const launcher = createAppDir('.mount_My FluOjOANH'); assert.equal(isRunningFromAppImage({}, extracted.binary), false); assert.equal( isRunningFromAppImage({APPDIR: launcher.dir}, extracted.binary), false, 'the generated AppRun assigns APPDIR without exporting it, so an APPDIR that reaches this process and does not contain it came from another AppImage', ); }); test('refuses an installed package that inherited APPIMAGE from the terminal it was started in', () => { const {isRunningFromAppImage} = loadAppImageUpdate({}); const terminal = createAppDir('.mount_Alacritty7fJk2L'); assert.equal(isRunningFromAppImage({}, '/opt/Fluxer/fluxer'), false); assert.equal(isRunningFromAppImage({APPDIR: ''}, '/opt/Fluxer/fluxer'), false); assert.equal(isRunningFromAppImage({APPDIR: terminal.dir}, '/opt/Fluxer/fluxer'), false); }); test('refuses a mount path that only prefixes this executable', () => { const {isRunningFromAppImage} = loadAppImageUpdate({}); const mounted = createAppDir('.mount_My FluAKcenK'); assert.equal(isRunningFromAppImage({APPDIR: mounted.dir}, `${mounted.dir}-other/fluxer-canary`), false); }); test('refuses an APPDIR the runtime cannot have mounted', () => { const {isRunningFromAppImage} = loadAppImageUpdate({}); const installDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-')); const binary = join(installDir, 'fluxer-canary'); writeFileSync(binary, 'elf', {mode: 0o755}); assert.equal(isRunningFromAppImage({APPDIR: installDir}, binary), false); writeFileSync(join(installDir, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755}); assert.equal(isRunningFromAppImage({APPDIR: installDir}, binary), true); }); }); describe('AppImageUpdate target resolution', () => { test('falls back when APPIMAGE is unset', () => { const {resolveAppImageTarget} = loadAppImageUpdate({}); assertUnavailable(resolveAppImageTarget(), 'appimage-path-missing'); assertUnavailable(resolveAppImageTarget(''), 'appimage-path-missing'); assertUnavailable(resolveAppImageTarget(' '), 'appimage-path-missing'); }); test('falls back when the AppImage moved since launch', () => { const install = createInstall(); const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: join(install.applications, 'Gone.AppImage')}); assertUnavailable(resolveAppImageTarget(), 'appimage-path-gone'); }); test('falls back when the path is not a regular file', () => { const install = createInstall(); const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.applications}); assertUnavailable(resolveAppImageTarget(), 'appimage-path-not-a-file'); }); test('falls back when the containing directory is not writable', {skip: process.getuid?.() === 0}, () => { const install = createInstall(); chmodSync(install.applications, 0o500); try { const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.installedPath}); assertUnavailable(resolveAppImageTarget(), 'directory-not-writable'); } finally { chmodSync(install.applications, 0o755); } }); test('resolves a writable AppImage to its own path', () => { const install = createInstall(); const {resolveAppImageTarget} = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = resolveAppImageTarget(); assert.equal(resolved.ok, true); assert.equal(resolved.target.installedPath, install.installedPath); assert.equal(resolved.target.directory, install.applications); }); }); describe('AppImageUpdate in-place replacement', () => { test('replaces the running AppImage and keeps the user filename', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); assert.equal(resolved.ok, true); const progress = []; const staged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, onProgress: (sample) => progress.push(sample), }); assert.equal(staged.stagedPath, join(staged.stagingDirectory, INSTALLED_NAME)); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); assert.ok(progress.length > 0); assert.equal(progress.at(-1).transferred, NEW_BYTES.length); assert.equal(progress.at(-1).total, NEW_BYTES.length); appImageUpdate.applyStagedAppImageUpdate(resolved.target, staged); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true); assert.equal(createHash('sha256').update(readFileSync(install.installedPath)).digest('hex'), NEW_SHA256); assert.equal(statSync(install.installedPath).mode & 0o111, 0o111); }); test('stages inside the install directory so the rename cannot cross filesystems', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); assert.equal(staged.stagingDirectory.startsWith(join(install.applications, '.fluxer-update-')), true); appImageUpdate.discardStagedAppImageUpdate(staged); assert.deepEqual(stagingLeftovers(install.applications), []); }); test('a download that stops sending data fails instead of hanging and leaves nothing staged', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const silent = new ReadableStream({ start(controller) { controller.enqueue(NEW_BYTES.subarray(0, 4)); }, }); await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: async () => new Response(silent, {headers: {'content-length': String(NEW_BYTES.length)}}), target: resolved.target, url: 'https://pkgs.invalid/appimage', expectedSha256: NEW_SHA256, stallMs: 30, }), /sent no data for 30 ms/, ); assert.deepEqual(stagingLeftovers(install.applications), []); }); test('refuses to swap when the download does not match the published checksum', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/corrupt`, expectedSha256: NEW_SHA256, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageChecksumError, true); assert.match(error.message, /checksum mismatch/); return true; }, ); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('refuses a checksum the release feed never published', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const before = requestCount; await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: '', }), /checksum is missing or malformed/, ); assert.equal(requestCount, before); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('cleans up and leaves the install untouched when the download fails', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/nothing-here`, expectedSha256: NEW_SHA256, }), /Update download failed: 404/, ); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('finishes a short write instead of installing a truncated AppImage', async () => { const install = createInstall(); let shortWrites = 0; const shortWritingRequire = (specifier) => { const real = require(specifier); if (specifier !== 'node:fs/promises') { return real; } return { ...real, open: async (...args) => { const handle = await real.open(...args); return { close: () => handle.close(), datasync: () => handle.datasync(), write: (buffer, offset, length) => { const capped = shortWrites === 0 ? Math.min(length, 4096) : length; shortWrites += 1; return handle.write(buffer, offset, capped); }, }; }, }; }; const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, shortWritingRequire); const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); assert.ok(shortWrites > 1, 'the write loop has to come back for the remainder of a short write'); assert.equal(statSync(staged.stagedPath).size, NEW_BYTES.length); assert.equal(createHash('sha256').update(readFileSync(staged.stagedPath)).digest('hex'), NEW_SHA256); appImageUpdate.discardStagedAppImageUpdate(staged); }); test('checksums the staged file from disk, not the stream it was read from', async () => { const install = createInstall(); const droppingRequire = (specifier) => { const real = require(specifier); if (specifier !== 'node:fs/promises') { return real; } return { ...real, open: async (...args) => { const handle = await real.open(...args); return { close: () => handle.close(), datasync: () => handle.datasync(), write: async (buffer, offset, length) => { const kept = Math.max(0, length - 1024); if (kept > 0) { await handle.write(buffer, offset, kept); } return {bytesWritten: length}; }, }; }, }; }; const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, droppingRequire); const resolved = appImageUpdate.resolveAppImageTarget(); await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageChecksumError, true); return true; }, ); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('flushes the staged file to disk before it can be renamed over the install', async () => { const install = createInstall(); let flushes = 0; const countingRequire = (specifier) => { const real = require(specifier); if (specifier !== 'node:fs/promises') { return real; } return { ...real, open: async (...args) => { const handle = await real.open(...args); return { close: () => handle.close(), datasync: () => { flushes += 1; return handle.datasync(); }, write: (buffer, offset, length) => handle.write(buffer, offset, length), }; }, }; }; const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, countingRequire); const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); assert.equal(flushes, 1); appImageUpdate.discardStagedAppImageUpdate(staged); }); test('gives up when the staged bytes cannot be flushed to disk', async () => { const install = createInstall(); const failingFlushRequire = (specifier) => { const real = require(specifier); if (specifier !== 'node:fs/promises') { return real; } return { ...real, open: async (...args) => { const handle = await real.open(...args); return { close: () => handle.close(), datasync: async () => { const error = new Error('ENOSPC: no space left on device, fsync'); error.code = 'ENOSPC'; throw error; }, write: (buffer, offset, length) => handle.write(buffer, offset, length), }; }, }; }; const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}, {}, failingFlushRequire); const resolved = appImageUpdate.resolveAppImageTarget(); await assert.rejects( appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); assert.equal(error.code, 'ENOSPC'); return true; }, ); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('retries a body that stops short of content-length instead of calling it a bad build', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const truncatingFetch = async () => { let sent = false; return { ok: true, status: 200, headers: {get: (name) => (name === 'content-length' ? String(NEW_BYTES.length) : null)}, body: { cancel: async () => {}, getReader: () => ({ cancel: async () => {}, read: async () => { if (sent) { return {done: true, value: undefined}; } sent = true; return {done: false, value: NEW_BYTES.subarray(0, 1024)}; }, }), }, }; }; await assert.rejects( appImageUpdate.stageAppImageUpdate({ target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, fetchImpl: truncatingFetch, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageChecksumError, false); assert.equal(error instanceof appImageUpdate.AppImageStagingError, false); assert.match(error.message, new RegExp(`ended after 1024 of ${NEW_BYTES.length} bytes`)); return true; }, ); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('refuses to stage when the install filesystem cannot hold the download', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const hugeFetch = async () => ({ ok: true, status: 200, headers: {get: (name) => (name === 'content-length' ? String(Number.MAX_SAFE_INTEGER) : null)}, body: { cancel: async () => {}, getReader: () => ({cancel: async () => {}, read: async () => ({done: true, value: undefined})}), }, }); await assert.rejects( appImageUpdate.stageAppImageUpdate({ target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, fetchImpl: hugeFetch, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); assert.equal(error.code, 'ENOSPC'); return true; }, ); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('marks a local write failure as non-retryable', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const failingFetch = async () => ({ ok: true, status: 200, headers: {get: () => null}, body: { cancel: async () => {}, getReader: () => ({ cancel: async () => {}, read: async () => { const error = new Error('ENOSPC: no space left on device, write'); error.code = 'ENOSPC'; throw error; }, }), }, }); await assert.rejects( appImageUpdate.stageAppImageUpdate({ target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, fetchImpl: failingFetch, }), (error) => { assert.equal(error instanceof appImageUpdate.AppImageStagingError, true); assert.equal(error.code, 'ENOSPC'); return true; }, ); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); test('refuses to install when the AppImage moved between download and install', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); const relocated = {installedPath: join(install.applications, 'Moved.AppImage'), directory: install.applications}; assert.throws( () => appImageUpdate.applyStagedAppImageUpdate(relocated, staged), /cannot be replaced in place: appimage-path-gone/, ); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); assert.deepEqual(stagingLeftovers(install.applications), []); }); }); describe('AppImageUpdate abandoned staging cleanup', () => { test('reclaims staged updates left behind by a process that never quit cleanly', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const abandoned = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); assert.equal(stagingLeftovers(install.applications).length, 1); const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); assert.deepEqual([...reclaimed], [abandoned.stagingDirectory]); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true); }); test('leaves a staged update belonging to another AppImage in the same directory alone', async () => { const install = createInstall(); const siblingPath = join(install.applications, 'Fluxer Canary.AppImage'); writeFileSync(siblingPath, OLD_BYTES, {mode: 0o755}); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const sibling = appImageUpdate.resolveAppImageTarget(siblingPath); const siblingStaged = await appImageUpdate.stageAppImageUpdate({ fetchImpl: fetch, target: sibling.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, }); const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); assert.deepEqual([...reclaimed], []); assert.equal(existsSync(siblingStaged.stagedPath), true); appImageUpdate.discardStagedAppImageUpdate(siblingStaged); assert.deepEqual(stagingLeftovers(install.applications), []); }); test('unlinks a staging-named symlink instead of following it out of the install directory', () => { const install = createInstall(); const outside = mkdtempSync(join(tmpdir(), 'fluxer-outside-test-')); writeFileSync(join(outside, 'keep-me'), 'not ours to delete'); const planted = join(install.applications, `${stagingPrefixFor(install.installedPath)}link`); symlinkSync(outside, planted); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const reclaimed = appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target); assert.deepEqual([...reclaimed], [planted]); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); assert.deepEqual(readdirSync(outside), ['keep-me']); }); test('leaves everything that is not one of this AppImage staging directories alone', () => { const install = createInstall(); const neighbours = ['Fluxer Canary.AppImage', '.fluxer-update-notes.txt', '.fluxer-update-']; for (const name of neighbours) { writeFileSync(join(install.applications, name), 'keep'); } const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); assert.deepEqual([...appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target)], []); assert.deepEqual(readdirSync(install.applications).sort(), [...neighbours, INSTALLED_NAME].sort()); }); test('reports nothing when the install directory holds no staged update', () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); assert.deepEqual([...appImageUpdate.sweepAbandonedAppImageUpdates(resolved.target)], []); assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]); }); });