diff --git a/tools/public/fpagent/install.sh b/tools/public/fpagent/install.sh index f1ec1fd..e4d72a3 100755 --- a/tools/public/fpagent/install.sh +++ b/tools/public/fpagent/install.sh @@ -51,10 +51,18 @@ for arg in "$@"; do esac done -for cmd in curl tar sha256sum python3; do +for cmd in curl tar sha256sum python3 find; do command -v "$cmd" >/dev/null 2>&1 || die "$cmd is required but not installed" done +tmpbase="${TMPDIR:-/tmp}" +# Sweep scratch left by an earlier run that was SIGKILLed: the EXIT trap cannot +# run then (that is the one signal it cannot catch). Scoped to our own prefix, +# this user, and dirs older than a day, so a concurrent install is never touched. +# Runs before any network work so cleanup is not skipped when the fetch fails. +find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \ + -name 'fpagent-install.*' -mtime +0 -exec rm -rf {} + 2>/dev/null || true + # Offline bundles ship the libfprint .deb closure for a specific Ubuntu # release; prefer the one matching this machine, else fall back to the generic # online bundle (whose installer apt-get's the dependency). @@ -85,7 +93,7 @@ asset="${readout#*$'\t'}" say " release ${tag}" say " bundle ${asset}" -tmp="$(mktemp -d)" +tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")" trap 'rm -rf "$tmp"' EXIT get() { curl -fsSL "$1" -o "$2" || die "download failed: $1"; } diff --git a/tools/test/fpagent-install.test.js b/tools/test/fpagent-install.test.js index 5564d44..f87d4cc 100644 --- a/tools/test/fpagent-install.test.js +++ b/tools/test/fpagent-install.test.js @@ -4,11 +4,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createApp, FPAGENT_REPO } from '../server.js'; import { startServer, stopServer, urlOf } from './helpers.js'; +const SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'public', + 'fpagent', + 'install.sh' +); + const TOKEN = 'release-install-secret'; const TAG = 'v9.9.9'; const ARCHIVE = 'fpagent-deadbee-linux-x86_64.tar.gz'; @@ -150,12 +160,37 @@ test('install.sh is served publicly at /fpagent/install.sh', async () => { }); test('install.sh is valid bash', () => { - const script = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'public', - 'fpagent', - 'install.sh' - ); - execFileSync('bash', ['-n', script], { stdio: 'pipe' }); + execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' }); +}); + +test('install.sh sweeps only its own stale scratch dirs', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-sweep-test-')); + const stale = path.join(base, 'fpagent-install.oldoldold'); + const fresh = path.join(base, 'fpagent-install.freshfresh'); + const unrelated = path.join(base, 'someone-else.oldold'); + for (const dir of [stale, fresh, unrelated]) fs.mkdirSync(dir); + const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(stale, old, old); + fs.utimesSync(unrelated, old, old); + + try { + // The base URL is unreachable, so the run fails after the sweep: enough to + // exercise the cleanup without downloading anything. + let failed = false; + try { + execFileSync('bash', [SCRIPT], { + stdio: 'pipe', + env: { ...process.env, TMPDIR: base, FPAGENT_BASE_URL: 'http://127.0.0.1:9' }, + }); + } catch { + failed = true; + } + assert.equal(failed, true, 'the installer should fail on the unreachable base'); + + assert.equal(fs.existsSync(stale), false, 'stale fpagent scratch should be removed'); + assert.equal(fs.existsSync(fresh), true, 'fresh (possibly concurrent) scratch should survive'); + assert.equal(fs.existsSync(unrelated), true, 'another prefix should never be touched'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } });