diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index 9d60d6f..3c06d2d 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -11,6 +11,9 @@ # Requirements: bash, node/npm, dpkg-deb, gzip. Runs lintian when available. set -euo pipefail +# The payload must be world-readable regardless of the builder's umask +# (with umask 077, `stoke` would be unreadable for non-root after install). +umask 022 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" VERSION="$(node -p "require('$ROOT/package.json').version")" @@ -68,8 +71,9 @@ EOF gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz" # Normalize permissions regardless of the builder's umask: no group/other -# write anywhere, executable entry point. +# write anywhere, everything readable, executable entry point. chmod -R go-w "$PKG/usr" +chmod -R a+rX "$PKG/usr" chmod 0755 "$LIB/src/cli.js" # --- control ----------------------------------------------------------------- diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index a64e185..55d09de 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -106,7 +106,19 @@ fi # in the upstream signing library). Try the properly signed source first so # this heals automatically once the forge is fixed; otherwise fall back to # [trusted=yes] — package integrity then relies on HTTPS to our own forge. -if ! update_only_source "$LIST"; then +# The fallback is only justified for an actual signature-verification +# failure: a transient network error must not permanently disable +# verification, so any other `apt-get update` failure is fatal. +update_failed="" +update_output="$(update_only_source "$LIST" 2>&1)" || update_failed=1 +[ -z "$update_output" ] || echo "$update_output" +if [ -n "$update_failed" ]; then + if ! echo "$update_output" | grep -Eiq 'NO_PUBKEY|KEYEXPIRED|not signed|no longer signed|signature'; then + echo "error: apt-get update failed for the new source (see above), but not" >&2 + echo "with a signature-verification error. Refusing to fall back to" >&2 + echo "[trusted=yes]; fix the underlying problem and re-run." >&2 + exit 1 + fi echo echo "WARNING: signature verification failed (known Forgejo registry issue" >&2 echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2 diff --git a/scripts/publish-deb.sh b/scripts/publish-deb.sh index 1e7e3a6..aff2487 100755 --- a/scripts/publish-deb.sh +++ b/scripts/publish-deb.sh @@ -26,17 +26,37 @@ COMPONENT="${4:-main}" [ -f "$DEB" ] || { echo "error: no such file: $DEB" >&2; exit 1; } -CONFIG_JSON="$(node -e "const c = require('$ROOT/src/config').loadConfig(); if (c) process.stdout.write(JSON.stringify(c));" 2>/dev/null || true)" -TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}" -FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}" +# Read the config field inside node so the values (token included) never +# pass through this script's argv or environment, where they would show up +# in the process list. A corrupt config makes node fail with the real parse +# error — surfaced by `set -e` — instead of a misleading "no token" message. +read_config_field() { + node -e " + const c = require('$ROOT/src/config').loadConfig(); + process.stdout.write(String((c && c.$1) || '')); + " +} + +TOKEN="${STOKE_TOKEN:-$(read_config_field token)}" +FORGE_URL="${FORGE_URL:-$(read_config_field url)}" +FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}" [ -n "$TOKEN" ] || { echo "error: no token. Set STOKE_TOKEN or run: stoke auth login" >&2; exit 1; } +# The token goes to curl through a config file (passed with -K) instead of a +# -H argument so it never appears in the process list; response body and +# config file are mktemp'd and cleaned up on exit. +CURL_CONFIG="$(mktemp)" +RESPONSE="$(mktemp)" +trap 'rm -f "$CURL_CONFIG" "$RESPONSE"' EXIT +chmod 0600 "$CURL_CONFIG" +printf 'header = "Authorization: token %s"\n' "$TOKEN" > "$CURL_CONFIG" + URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" echo "Uploading $(basename "$DEB") to $URL" -STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \ - -X PUT -H "Authorization: token $TOKEN" \ +STATUS="$(curl -sS -o "$RESPONSE" -w '%{http_code}' --max-time 300 \ + -X PUT -K "$CURL_CONFIG" \ --upload-file "$DEB" "$URL")" case "$STATUS" in @@ -44,9 +64,7 @@ case "$STATUS" in 409) echo "Already published (409): this exact version already exists in the registry." ;; *) echo "error: upload failed with HTTP $STATUS" >&2 - cat /tmp/stoke-publish-response.$$ >&2 || true - rm -f /tmp/stoke-publish-response.$$ + cat "$RESPONSE" >&2 || true exit 1 ;; esac -rm -f /tmp/stoke-publish-response.$$ diff --git a/src/cli.js b/src/cli.js index 08a4099..51ff54d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -194,7 +194,7 @@ auth config = { url, login: me.login, - username: me.username || me.login, + username: me.login, email: me.email, token, tokenId: null, @@ -213,7 +213,7 @@ auth config = { url, login, - username: me.username || login, + username: login, email: me.email, token: tokenRes.sha1, tokenId: tokenRes.id, @@ -263,6 +263,11 @@ auth } else { console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`); } + } else if (!config.tokenId && !options.localOnly) { + // A manually supplied token (-t/--token-file) has no id stoke knows, + // so it cannot be revoked remotely. Say so instead of leaving the + // user thinking logout disabled it. + console.log(`The stored token was provided manually and cannot be revoked remotely; it stays active on ${config.url}. Revoke it from the web UI under Settings > Applications.`); } clearConfig(); @@ -282,7 +287,7 @@ auth const config = loadConfig(); if (!config || !config.token) { console.log('Not authenticated.'); - return; + process.exit(1); } const client = ForgejoClient.fromConfig(config); @@ -293,7 +298,7 @@ auth } console.log('Instance: ', config.url); console.log('Login: ', me.login); - console.log('Username: ', me.username); + console.log('Username: ', me.login); console.log('Email: ', me.email); console.log('Token path: ', getConfigPath()); } catch (err) { @@ -574,29 +579,31 @@ repo } const service = item.service || 'github'; - const isPrivate = item.public ? false : Boolean(item.private); - const payload = { - clone_addr: from, - repo_name: name, - repo_owner: item.owner || item.repo_owner || config.login, - service, - description: item.description || undefined, - private: isPrivate, - issues: normalizeBool(item.issues, true), - labels: normalizeBool(item.labels, true), - milestones: normalizeBool(item.milestones, true), - pull_requests: normalizeBool(item.pull_requests, true), - releases: normalizeBool(item.releases, true), - wiki: normalizeBool(item.wiki, true), - lfs: normalizeBool(item.lfs, false), - auth_token: resolveSourceToken(item.github_token, service), - }; - - Object.keys(payload).forEach((key) => { - if (payload[key] === undefined) delete payload[key]; - }); - try { + // Token resolution happens per item, inside the try: a missing + // source token must fail this item, not abort the whole batch. + const isPrivate = item.public ? false : Boolean(item.private); + const payload = { + clone_addr: from, + repo_name: name, + repo_owner: item.owner || item.repo_owner || config.login, + service, + description: item.description || undefined, + private: isPrivate, + issues: normalizeBool(item.issues, true), + labels: normalizeBool(item.labels, true), + milestones: normalizeBool(item.milestones, true), + pull_requests: normalizeBool(item.pull_requests, true), + releases: normalizeBool(item.releases, true), + wiki: normalizeBool(item.wiki, true), + lfs: normalizeBool(item.lfs, false), + auth_token: resolveSourceToken(item.github_token, service), + }; + + Object.keys(payload).forEach((key) => { + if (payload[key] === undefined) delete payload[key]; + }); + const result = await client.migrateRepo(payload); console.log(`Imported: ${result.full_name} -> ${result.html_url}`); results.push({ name, status: 'ok', url: result.html_url }); diff --git a/test/build-deb.test.js b/test/build-deb.test.js new file mode 100644 index 0000000..3e5f88b --- /dev/null +++ b/test/build-deb.test.js @@ -0,0 +1,46 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'build-deb.sh'); +const pkg = require('../package.json'); + +// Builds the .deb with a restrictive umask (npm stubbed out — dependency +// installation is irrelevant to permissions and would need the network) and +// asserts the payload is world-readable: with umask 077 and no explicit +// normalization, `stoke` would be unusable for non-root after install. +test('payload files are world-readable even when built with umask 077', () => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-build-test-')); + fs.writeFileSync(path.join(bin, 'npm'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }); + const distDir = path.join(ROOT, 'dist'); + const deb = path.join(distDir, `stoke_${pkg.version}_all.deb`); + const distExisted = fs.existsSync(distDir); + try { + const res = spawnSync('bash', ['-c', 'umask 077 && exec bash "$1"', 'bash', SCRIPT], { + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + assert.equal(res.status, 0, res.stderr); + + const listing = execFileSync('bash', ['-c', 'dpkg-deb --fsys-tarfile "$1" | tar -tv', 'bash', deb], { + encoding: 'utf8', + }); + const entries = listing.trim().split('\n').filter((l) => l.includes('/usr/')); + assert.ok(entries.length > 0, 'payload listing must not be empty'); + for (const line of entries) { + const perms = line.split(/\s+/)[0]; + if (perms.startsWith('l')) continue; // symlink target perms are irrelevant + assert.equal(perms[7], 'r', `not world-readable: ${line}`); + assert.equal(perms[5], '-', `group-writable: ${line}`); + assert.equal(perms[8], '-', `other-writable: ${line}`); + } + } finally { + fs.rmSync(bin, { recursive: true, force: true }); + fs.rmSync(deb, { force: true }); + if (!distExisted) fs.rmSync(distDir, { recursive: true, force: true }); + } +}); diff --git a/test/cli.test.js b/test/cli.test.js index e08255c..e838085 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -40,7 +40,14 @@ test('global --config flag overrides the config location', () => { // "Not authenticated" instead of silently using the default config. const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`); const res = run(['--config', missing, 'auth', 'status']); - assert.equal(res.status, 0); + assert.equal(res.status, 1); + assert.match(res.stdout, /Not authenticated/); +}); + +test('auth status exits 1 when not authenticated', () => { + const missing = path.join(os.tmpdir(), `stoke-none-${process.pid}.json`); + const res = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing }); + assert.equal(res.status, 1); assert.match(res.stdout, /Not authenticated/); }); @@ -720,3 +727,111 @@ test('pr review --commit sends commit_id only when given', async () => { fs.unlinkSync(cfg); } }); + +test('auth status prints the login field instead of undefined', async () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-status.json`); + const TIMEOUT_MS = 5000; + let timer; + const result = await new Promise((resolve, reject) => { + const fail = (err) => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + reject(err instanceof Error ? err : new Error(String(err))); + }; + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ login: 'bot', email: 'bot@forge.test' })); + }); + timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS); + server.listen(0, '127.0.0.1', async () => { + const { port } = server.address(); + fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' })); + try { + const res = await spawnAsync(['auth', 'status'], { STOKE_CONFIG_FILE: cfg }); + clearTimeout(timer); + server.close(() => resolve(res)); + } catch (err) { + fail(err); + } + }); + }).finally(() => clearTimeout(timer)); + + try { + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Login:\s+bot/); + assert.match(result.stdout, /Username:\s+bot/); + assert.doesNotMatch(result.stdout, /undefined/); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('auth logout warns that a manually supplied token stays active on the server', () => { + // tokenId: null is what `auth login -t/--token-file` stores. + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-logout.json`); + fs.writeFileSync(cfg, JSON.stringify({ + url: 'https://forge.test', login: 'alice', username: 'alice', token: 'tok', tokenId: null, + })); + const res = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /Settings > Applications/); + assert.match(res.stdout, /Local credentials removed/); + assert.equal(fs.existsSync(cfg), false, 'config must be removed'); +}); + +test('repo import-batch fails the item with a missing source token but continues the batch', async () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-batch.json`); + const manifest = path.join(os.tmpdir(), `stoke-manifest-${process.pid}.json`); + // A PATH without `gh` and no GITHUB_TOKEN makes token resolution fail for + // the first (tokenless) GitHub item; the second carries its own token. + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-empty-bin-')); + fs.writeFileSync(manifest, JSON.stringify([ + { name: 'bad', from: 'https://github.com/o/bad.git' }, + { name: 'good', from: 'https://github.com/o/good.git', github_token: 'gh-tok' }, + ])); + const TIMEOUT_MS = 5000; + let timer; + const result = await new Promise((resolve, reject) => { + const fail = (err) => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + reject(err instanceof Error ? err : new Error(String(err))); + }; + const migrated = []; + const server = http.createServer((req, res) => { + let data = ''; + req.on('data', (c) => { data += c; }); + req.on('end', () => { + migrated.push(JSON.parse(data).repo_name); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ full_name: `o/${JSON.parse(data).repo_name}`, html_url: 'https://forge.test/o/r' })); + }); + }); + timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS); + server.listen(0, '127.0.0.1', async () => { + const { port } = server.address(); + fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok', login: 'o' })); + // Empty PATH hides `gh`; empty GITHUB_TOKEN is falsy for the CLI. + const env = { STOKE_CONFIG_FILE: cfg, PATH: emptyBin, GITHUB_TOKEN: '' }; + try { + const res = await spawnAsync(['repo', 'import-batch', '-f', manifest], env); + clearTimeout(timer); + server.close(() => resolve({ res, migrated })); + } catch (err) { + fail(err); + } + }); + }).finally(() => clearTimeout(timer)); + + try { + assert.equal(result.res.status, 1, 'partial batch must exit 1'); + assert.match(result.res.stderr, /Failed to import bad: No GitHub token found/); + assert.match(result.res.stdout, /Imported: o\/good/); + assert.match(result.res.stdout, /Batch complete: 1\/2 imported/); + assert.deepEqual(result.migrated, ['good'], 'only the valid item may reach the server'); + } finally { + fs.unlinkSync(cfg); + fs.unlinkSync(manifest); + fs.rmSync(emptyBin, { recursive: true, force: true }); + } +}); diff --git a/test/install-apt.test.js b/test/install-apt.test.js index f411c39..1311094 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -13,12 +13,14 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // candAfterUpdate Candidate after any `apt-get update` // candAfterNodesource Candidate after an update once nodesource.list exists // releaseStatus HTTP status curl reports for the registry Release file +// updateFailOutput when set, `apt-get update` against the forgejo source +// fails with this output until the list is [trusted=yes] // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, updateFailOutput }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -58,8 +60,10 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi ].join('\n')); stub('apt-get', [ 'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"', + 'is_update=0', 'for a in "$@"; do', ' if [ "$a" = update ]; then', + ' is_update=1', ' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then', ' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"', ' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then', @@ -67,6 +71,17 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi ' fi', ' fi', 'done', + // Updates of the forgejo source fail with UPDATE_FAIL_OUTPUT until the + // list is rewritten with [trusted=yes], like sqv rejecting the signature. + 'if [ "$is_update" = 1 ] && [ -n "${UPDATE_FAIL_OUTPUT:-}" ]; then', + ' case " $* " in', + ' *forgejo*)', + ' if ! grep -q "trusted=yes" "$STOKE_APT_ETC/sources.list.d/forgejo-heavy-duty.list" 2>/dev/null; then', + ' echo "$UPDATE_FAIL_OUTPUT"', + ' exit 1', + ' fi;;', + ' esac', + 'fi', 'exit 0', ].join('\n')); @@ -82,6 +97,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi CAND_AFTER_UPDATE: candAfterUpdate || '', CAND_AFTER_NODESOURCE: candAfterNodesource || '', RELEASE_STATUS: releaseStatus || '', + UPDATE_FAIL_OUTPUT: updateFailOutput || '', LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C }, }); @@ -96,6 +112,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')), nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')), forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.asc')), + forgeList: read(path.join(aptEtc, 'sources.list.d', 'forgejo-heavy-duty.list')), aptGetLog: read(path.join(state, 'apt-get.log')) || '', }; // Drop the throwaway tree after we have read everything we need. @@ -171,3 +188,25 @@ test('registry Release file present: proceeds with the install', () => { assert.equal(s.res.status, 0, s.res.stderr); assert.match(s.aptGetLog, /install -y stoke/); }); + +test('apt update network failure: refuses to fall back to [trusted=yes]', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + updateFailOutput: 'Err:1 https://forge.test heavy-duty InRelease\n Could not resolve host: forge.test', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, /Refusing to fall back/); + assert.doesNotMatch(s.forgeList, /trusted=yes/, 'source must stay signature-verified'); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + +test('apt update signature failure: falls back to [trusted=yes] and installs', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + updateFailOutput: 'E: The repository \'https://forge.test heavy-duty InRelease\' is not signed.', + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.match(s.res.stderr, /WARNING: signature verification failed/); + assert.match(s.forgeList, /deb \[trusted=yes\] /); + assert.match(s.aptGetLog, /install -y stoke/); +});