// B1/#1827 r2: rejecting an unusable key must leave both complete trees unchanged. import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import http from 'node:http'; import { sha256, validManagementKey } from '../src/common.mjs'; import { initializeProxy, installPaths } from '../src/settings.mjs'; import { ManagementClient } from '../src/client.mjs'; const pin = { version: '8.0.42', binary: { sha256: sha256('fixture binary') }, panel: { sha256: sha256('fixture panel') } }; function fixture(t) { const root = fs.mkdtempSync(join(tmpdir(), 'p1-encoding-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const configDir = join(root, 'config'), dataRoot = join(root, 'data'); fs.mkdirSync(join(configDir, 'secrets'), { recursive: true, mode: 0o700 }); fs.mkdirSync(join(dataRoot, 'existing'), { recursive: true, mode: 0o700 }); fs.writeFileSync(join(configDir, 'config.json'), '{"fixture":"unchanged"}\n', { mode: 0o600 }); fs.writeFileSync(join(configDir, 'secrets', 'existing'), 'fixture-existing', { mode: 0o600 }); fs.writeFileSync(join(dataRoot, 'existing', 'marker'), 'fixture-data', { mode: 0o600 }); const installed = installPaths(dataRoot, pin); fs.mkdirSync(join(installed.dir, 'static'), { recursive: true, mode: 0o700 }); fs.writeFileSync(installed.binary, 'fixture binary', { mode: 0o755 }); fs.writeFileSync(installed.panel, 'fixture panel', { mode: 0o600 }); return { configDir, dataRoot }; } function inventory(root) { const entries = []; function walk(file, relative) { const s = fs.lstatSync(file, { bigint: true }); const entry = { path: relative, kind: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : s.isSymbolicLink() ? 'symlink' : 'other', mode: (s.mode & 0o7777n).toString(8), uid: String(s.uid), gid: String(s.gid), inode: String(s.ino), device: String(s.dev), modified: String(s.mtimeNs), changed: String(s.ctimeNs), }; // Read-only inventory may update atime; it must not alter identity/content/mode or mtime/ctime. if (s.isFile()) { entry.size = String(s.size); entry.sha256 = sha256(fs.readFileSync(file)); } if (s.isSymbolicLink()) entry.target = fs.readlinkSync(file); entries.push(entry); if (s.isDirectory()) for (const child of fs.readdirSync(file).sort()) walk(join(file, child), relative === '.' ? child : relative + '/' + child); } walk(root, '.'); return entries; } const inventories = f => ({ config: inventory(f.configDir), data: inventory(f.dataRoot) }); for (const [label, key] of [ ['Latin-1', 'fx329-caf\u00e9'], ['leading space', ' fx329-ascii'], ['trailing space', 'fx329-ascii '], ['internal space', 'fx329 ascii'], ]) { test(`proxy-key-invalid: ${label} leaves complete config/data inventories unchanged`, async t => { const f = fixture(t), before = inventories(f); const outcome = await initializeProxy({ ...f, pin, key }).then(() => null, error => error.code); assert.deepEqual({ refusal: outcome, trees: inventories(f) }, { refusal: 'proxy-key-invalid', trees: before }); assert.equal(validManagementKey(key), false); }); } test('printable non-space ASCII control initializes and authenticates with unchanged existing files', async t => { const f = fixture(t), key = 'fx329-Az09!_~', before = inventories(f); assert.equal(validManagementKey(key), true); const settings = await initializeProxy({ ...f, pin, key }); const after = inventories(f); for (const area of ['config', 'data']) { for (const old of before[area].filter(entry => entry.kind === 'file')) assert.deepEqual(after[area].find(entry => entry.path === old.path), old); } const added = area => after[area].filter(entry => !before[area].some(old => old.path === entry.path)).map(entry => entry.path).sort(); assert.deepEqual(added('config'), ['proxy.json', 'secrets/proxy-management.key']); assert.deepEqual(added('data'), ['proxy', 'proxy/auth', 'proxy/config.yaml', 'proxy/logs']); assert.equal(fs.readFileSync(join(f.configDir, 'secrets/proxy-management.key'), 'utf8'), key); const config = JSON.parse(fs.readFileSync(join(f.dataRoot, 'proxy/config.yaml'), 'utf8')); const server = http.createServer((request, response) => { response.statusCode = request.headers.authorization === `Bearer ${key}` ? 200 : 401; response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(response.statusCode === 200 ? config.access['api-keys'] : { error: 'invalid' })); }); await new Promise(resolve => server.listen(settings.port, '127.0.0.1', resolve)); try { const client = new ManagementClient({ baseUrl: `http://127.0.0.1:${settings.port}`, key }); assert.deepEqual(await client.getKeys(), config.access['api-keys']); } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } });