From fa33c6e1795a55919392cd7bf3335bfee4d552c9 Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Tue, 6 Oct 2026 19:19:07 +0100 Subject: [PATCH] feat(fpagent): pick the installer bundle for the host architecture The hosted installer only ever chose the x86_64 bundle, so a 32-bit field laptop running the public one-liner failed with "no compatible bundle" even though fingerprint-agent v1.5.0 publishes fpagent--linux-i386.tar.gz. Resolve the host architecture from uname -m (x86_64 or i386, else fail before any download), then prefer -ubuntu--.tar.gz and fall back to -linux-.tar.gz. The version regex now derives the git hash for every supported architecture, so a 32-bit install records and reports it too. The help text matches the bundle installer: the systemd service and the self-update timer are on by default, with --no-service, --no-updates and --update-base documented. --- tools/public/fpagent/install.sh | 33 ++++++--- tools/test/fpagent-install.test.js | 103 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/tools/public/fpagent/install.sh b/tools/public/fpagent/install.sh index c94a47d..e875088 100755 --- a/tools/public/fpagent/install.sh +++ b/tools/public/fpagent/install.sh @@ -11,7 +11,7 @@ # own installer, which handles libfprint, the udev rule, plugdev and the # systemd service. Every flag is forwarded to that installer, e.g.: # -# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service +# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --service-user alice # curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # # The private release is reached through the tools app, so no credentials are @@ -35,12 +35,18 @@ Options: Options below are forwarded to the bundled installer: --check preflight and report only; change nothing --offline never touch the network; fail if dependencies are missing - --enable-service install and enable a systemd service (starts at boot) + --no-service do not install the systemd service (also disables updates) + --no-updates do not install the self-update timer + --update-base URL release base for self-update + (default: https://tools.yrka.app/fpagent) --service-user NAME user for the service (default: the invoking user) --install-path PATH binary destination (default: /usr/local/bin/fpagent) --uninstall remove the binary, the udev rule and the service -h, --help the installer's help +The systemd service and the self-update timer are installed and enabled by +default; --no-service and --no-updates turn them off. + Environment: FPAGENT_BASE_URL override the download base (default: ${BASE}) EOF @@ -85,6 +91,15 @@ find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \ # online bundle (whose installer apt-get's the dependency). codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')" +# Releases are named for the target architecture; only these two are published. +# Mirrors the bundled installer's own preflight so both reject the same hosts. +host_machine="$(uname -m)" +case "$host_machine" in + x86_64 | amd64) arch="x86_64" ;; + i386 | i486 | i586 | i686) arch="i386" ;; + *) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;; +esac + if [ "$RELEASE_TAG" = "latest" ]; then meta_url="${BASE}/releases/latest" else @@ -96,25 +111,25 @@ meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from $ readout="$(python3 -c ' import json, re, sys -codename = sys.argv[1] -release = json.loads(sys.argv[2]) +codename, arch = sys.argv[1], sys.argv[2] +release = json.loads(sys.argv[3]) assets = [a["name"] for a in release.get("assets", [])] def pick(suffix): return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "") -name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else "" +name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else "" if not name: - name = pick("-linux-x86_64.tar.gz") + name = pick("-linux-" + arch + ".tar.gz") # The git hash is baked into the asset name (bundle.sh --version) and into # the binary itself; it is the version we record and later compare against. -m = re.match(r"^fpagent-(.+)-(?:linux-x86_64|ubuntu-[a-z0-9]+-x86_64)\.tar\.gz$", name) +m = re.match(r"^fpagent-(.+)-(?:linux|ubuntu-[a-z0-9]+)-" + re.escape(arch) + r"\.tar\.gz$", name) version = m.group(1) if m else "" print(release.get("tag", "") + "\t" + name + "\t" + version) -' "$codename" "$meta")" || die "could not parse the release metadata" +' "$codename" "$arch" "$meta")" || die "could not parse the release metadata" IFS=$'\t' read -r tag asset version <<<"$readout" -[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (x86_64 required)" +[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)" say " release ${tag}" say " bundle ${asset}" diff --git a/tools/test/fpagent-install.test.js b/tools/test/fpagent-install.test.js index 739af5c..bebc670 100644 --- a/tools/test/fpagent-install.test.js +++ b/tools/test/fpagent-install.test.js @@ -37,6 +37,17 @@ const RELEASE = { ], }; +// A directory holding a fake `uname` that reports the given machine, so the +// installer's architecture-sensitive picker runs on any CI host. PATH is +// prepended with it; everything else resolves as usual. +function fakeUname(machine) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-')); + fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, { + mode: 0o755, + }); + return dir; +} + // Fake Gitea: refuses without the token, serves the release and its assets. async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) { const calls = []; @@ -231,6 +242,98 @@ test('install.sh is served publicly at /fpagent/install.sh', async () => { } }); +test('install.sh picks the bundle for the host architecture', async () => { + const calls = []; + const gateway = await startServer((req, res) => { + calls.push(req.url); + if (req.url === '/releases/latest') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + tag: 'v9.9.9', + assets: [ + { name: 'fpagent-deadbee-linux-x86_64.tar.gz' }, + { name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' }, + { name: 'fpagent-cafebabe-linux-i386.tar.gz' }, + { name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' }, + ], + }) + ); + return; + } + res.writeHead(404); + res.end(); + }); + const unameDir = fakeUname('i686'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-')); + try { + let out = ''; + try { + // Async so the in-process gateway can answer while the child runs. + const r = await execFileAsync('bash', [SCRIPT], { + env: { + ...process.env, + PATH: `${unameDir}:${process.env.PATH}`, + TMPDIR: tmp, + FPAGENT_BASE_URL: urlOf(gateway), + }, + }); + out = r.stdout + r.stderr; + } catch (e) { + out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s + } + assert.match(out, /cafebabe/, 'the i386 version should come from the asset name'); + assert.ok( + calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')), + 'should request the i386 bundle' + ); + assert.ok( + !calls.some((u) => u.includes('x86_64')), + 'should never request another architecture\u2019s bundle' + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(unameDir, { recursive: true, force: true }); + await stopServer(gateway); + } +}); + +test('install.sh refuses an unsupported architecture before fetching', async () => { + const calls = []; + const gateway = await startServer((req, res) => { + calls.push(req.url); + res.writeHead(404); + res.end(); + }); + const unameDir = fakeUname('aarch64'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-')); + try { + let out = ''; + let failed = false; + try { + const r = await execFileAsync('bash', [SCRIPT], { + env: { + ...process.env, + PATH: `${unameDir}:${process.env.PATH}`, + TMPDIR: tmp, + FPAGENT_BASE_URL: urlOf(gateway), + }, + }); + out = r.stdout + r.stderr; + } catch (e) { + failed = true; + out = `${e.stdout ?? ''}${e.stderr ?? ''}`; + } + assert.equal(failed, true, 'the installer should reject an unsupported host'); + assert.match(out, /unsupported architecture: aarch64/); + assert.equal(calls.length, 0, 'should fail before touching the network'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(unameDir, { recursive: true, force: true }); + await stopServer(gateway); + } +}); + test('install.sh is valid bash', () => { execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' }); });