Compare commits

...

1 commit

Author SHA1 Message Date
kimi-reviewer-andresmgsl
33079afb33 Harden publish/build/install scripts and fix audit findings
Some checks failed
ci / test (pull_request) Has been cancelled
- publish-deb: keep the token out of the process list (curl -K config
  file via mktemp, no JSON round-trip through node argv), mktemp the
  response file with trap cleanup, add --max-time to the upload
- build-deb: umask 022 + chmod -R a+rX so the payload is world-readable
  even when built with umask 077
- install-apt: only fall back to [trusted=yes] on an actual signature
  verification failure; other apt-get update failures stay fatal
- auth logout: warn that a manually supplied token stays active on the
  server and point at the web UI revocation page
- repo import-batch: resolve the source token inside the per-item try so
  one bad item no longer aborts the whole batch
- auth status: print me.login (the /user response has no username field)
  and exit 1 when not authenticated
2026-07-26 23:07:16 +00:00
7 changed files with 279 additions and 38 deletions

View file

@ -11,6 +11,9 @@
# Requirements: bash, node/npm, dpkg-deb, gzip. Runs lintian when available. # Requirements: bash, node/npm, dpkg-deb, gzip. Runs lintian when available.
set -euo pipefail 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)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(node -p "require('$ROOT/package.json').version")" VERSION="$(node -p "require('$ROOT/package.json').version")"
@ -68,8 +71,9 @@ EOF
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz" gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
# Normalize permissions regardless of the builder's umask: no group/other # 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 go-w "$PKG/usr"
chmod -R a+rX "$PKG/usr"
chmod 0755 "$LIB/src/cli.js" chmod 0755 "$LIB/src/cli.js"
# --- control ----------------------------------------------------------------- # --- control -----------------------------------------------------------------

View file

@ -106,7 +106,19 @@ fi
# in the upstream signing library). Try the properly signed source first so # in the upstream signing library). Try the properly signed source first so
# this heals automatically once the forge is fixed; otherwise fall back to # this heals automatically once the forge is fixed; otherwise fall back to
# [trusted=yes] — package integrity then relies on HTTPS to our own forge. # [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
echo "WARNING: signature verification failed (known Forgejo registry issue" >&2 echo "WARNING: signature verification failed (known Forgejo registry issue" >&2
echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2 echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2

View file

@ -26,17 +26,37 @@ COMPONENT="${4:-main}"
[ -f "$DEB" ] || { echo "error: no such file: $DEB" >&2; exit 1; } [ -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)" # Read the config field inside node so the values (token included) never
TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}" # pass through this script's argv or environment, where they would show up
FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}" # 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; } [ -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" URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
echo "Uploading $(basename "$DEB") to $URL" echo "Uploading $(basename "$DEB") to $URL"
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \ STATUS="$(curl -sS -o "$RESPONSE" -w '%{http_code}' --max-time 300 \
-X PUT -H "Authorization: token $TOKEN" \ -X PUT -K "$CURL_CONFIG" \
--upload-file "$DEB" "$URL")" --upload-file "$DEB" "$URL")"
case "$STATUS" in case "$STATUS" in
@ -44,9 +64,7 @@ case "$STATUS" in
409) echo "Already published (409): this exact version already exists in the registry." ;; 409) echo "Already published (409): this exact version already exists in the registry." ;;
*) *)
echo "error: upload failed with HTTP $STATUS" >&2 echo "error: upload failed with HTTP $STATUS" >&2
cat /tmp/stoke-publish-response.$$ >&2 || true cat "$RESPONSE" >&2 || true
rm -f /tmp/stoke-publish-response.$$
exit 1 exit 1
;; ;;
esac esac
rm -f /tmp/stoke-publish-response.$$

View file

@ -194,7 +194,7 @@ auth
config = { config = {
url, url,
login: me.login, login: me.login,
username: me.username || me.login, username: me.login,
email: me.email, email: me.email,
token, token,
tokenId: null, tokenId: null,
@ -213,7 +213,7 @@ auth
config = { config = {
url, url,
login, login,
username: me.username || login, username: login,
email: me.email, email: me.email,
token: tokenRes.sha1, token: tokenRes.sha1,
tokenId: tokenRes.id, tokenId: tokenRes.id,
@ -263,6 +263,11 @@ auth
} else { } 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.`); 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(); clearConfig();
@ -282,7 +287,7 @@ auth
const config = loadConfig(); const config = loadConfig();
if (!config || !config.token) { if (!config || !config.token) {
console.log('Not authenticated.'); console.log('Not authenticated.');
return; process.exit(1);
} }
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
@ -293,7 +298,7 @@ auth
} }
console.log('Instance: ', config.url); console.log('Instance: ', config.url);
console.log('Login: ', me.login); console.log('Login: ', me.login);
console.log('Username: ', me.username); console.log('Username: ', me.login);
console.log('Email: ', me.email); console.log('Email: ', me.email);
console.log('Token path: ', getConfigPath()); console.log('Token path: ', getConfigPath());
} catch (err) { } catch (err) {
@ -574,29 +579,31 @@ repo
} }
const service = item.service || 'github'; 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 { 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); const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`); console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url }); results.push({ name, status: 'ok', url: result.html_url });

46
test/build-deb.test.js Normal file
View file

@ -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 });
}
});

View file

@ -40,7 +40,14 @@ test('global --config flag overrides the config location', () => {
// "Not authenticated" instead of silently using the default config. // "Not authenticated" instead of silently using the default config.
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`); const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
const res = run(['--config', missing, 'auth', 'status']); 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/); assert.match(res.stdout, /Not authenticated/);
}); });
@ -720,3 +727,111 @@ test('pr review --commit sends commit_id only when given', async () => {
fs.unlinkSync(cfg); 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 });
}
});

View file

@ -13,12 +13,14 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
// candAfterUpdate Candidate after any `apt-get update` // candAfterUpdate Candidate after any `apt-get update`
// candAfterNodesource Candidate after an update once nodesource.list exists // candAfterNodesource Candidate after an update once nodesource.list exists
// releaseStatus HTTP status curl reports for the registry Release file // 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, // 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. // so every scenario doubles as a regression test for locale-safe parsing.
const cleanups = []; const cleanups = [];
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); 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-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
cleanups.push(root); cleanups.push(root);
const bin = path.join(root, 'bin'); const bin = path.join(root, 'bin');
@ -58,8 +60,10 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
].join('\n')); ].join('\n'));
stub('apt-get', [ stub('apt-get', [
'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"', 'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"',
'is_update=0',
'for a in "$@"; do', 'for a in "$@"; do',
' if [ "$a" = update ]; then', ' if [ "$a" = update ]; then',
' is_update=1',
' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then', ' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then',
' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"', ' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"',
' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then', ' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then',
@ -67,6 +71,17 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
' fi', ' fi',
' fi', ' fi',
'done', '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', 'exit 0',
].join('\n')); ].join('\n'));
@ -82,6 +97,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
CAND_AFTER_UPDATE: candAfterUpdate || '', CAND_AFTER_UPDATE: candAfterUpdate || '',
CAND_AFTER_NODESOURCE: candAfterNodesource || '', CAND_AFTER_NODESOURCE: candAfterNodesource || '',
RELEASE_STATUS: releaseStatus || '', RELEASE_STATUS: releaseStatus || '',
UPDATE_FAIL_OUTPUT: updateFailOutput || '',
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C 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')), nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')), nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')),
forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.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')) || '', aptGetLog: read(path.join(state, 'apt-get.log')) || '',
}; };
// Drop the throwaway tree after we have read everything we need. // 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.equal(s.res.status, 0, s.res.stderr);
assert.match(s.aptGetLog, /install -y stoke/); 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/);
});