// The client half of hosting, the parts that can go wrong without a server: // what a server-sent file list may touch, the key file, listing pages. import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { isTransferPath, transferTarget, downloadData, dataFiles, fileSecrets, EncryptingS3Client } from '../core/hosted.js'; import { driveKeys, encryptPath } from '../core/crypt.js'; const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-hosted-')); test('transfer paths: only s3d data, never ours, never outside', () => { for (const ok of ['s3d.db', 's3d.db-wal', 'uploads/02e9.obj']) assert.equal(isTransferPath(ok), true, ok); const bad = ['', '/etc/passwd', '../x', 'a/../b', './a', 'a//b', 'C:/x', 'a' + String.fromCharCode(92) + 'b', 's3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-hosted-secret.replaced-1.json', 'previous-account-20261004/s3d.db', 'tmp/x', 's3d.log']; for (const b of bad) assert.equal(isTransferPath(b), false, b); assert.throws(() => transferTarget('/data', '../outside'), /unsafe/); }); test('a hostile file list writes nothing and removes nothing', async () => { const dir = tmp(); const secret = path.join(dir, 'pithos-hosted-secret.json'); fs.writeFileSync(secret, 'precious'); const outside = path.join(path.dirname(dir), `victim-${crypto.randomBytes(4).toString('hex')}.txt`); fs.writeFileSync(outside, 'keep me'); const sha = 'a'.repeat(64); const written = []; const g = { base: 'http://127.0.0.1:9', auth: 'x' }; for (const files of [ [{ path: 's3d.db', size: 1, sha256: sha }, { path: `../${path.basename(outside)}`, size: 1, sha256: sha }], [{ path: 's3d.db', size: 1, sha256: sha }, { path: 'pithos-hosted-secret.json', size: 1, sha256: sha }], [{ path: 'uploads/x.obj', size: 1, sha256: sha }], ]) { await assert.rejects(downloadData(g, dir, files, () => {}, written)); } assert.equal(written.length, 0, 'nothing was started'); assert.equal(fs.readFileSync(secret, 'utf8'), 'precious'); assert.equal(fs.readFileSync(outside, 'utf8'), 'keep me'); fs.rmSync(outside); }); test('data files are listed with streamed hashes, skipping local files', async () => { const dir = tmp(); fs.writeFileSync(path.join(dir, 's3d.db'), 'db'); fs.mkdirSync(path.join(dir, 'uploads')); fs.writeFileSync(path.join(dir, 'uploads', 'a.obj'), 'obj'); for (const n of ['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 's3d.log']) fs.writeFileSync(path.join(dir, n), 'x'); fs.mkdirSync(path.join(dir, 'previous-account-1')); fs.writeFileSync(path.join(dir, 'previous-account-1', 's3d.db'), 'old'); const files = await dataFiles(dir); assert.deepEqual(files.map((f) => f.path).sort(), ['s3d.db', 'uploads/a.obj']); assert.equal(files.find((f) => f.path === 's3d.db').sha256, crypto.createHash('sha256').update('db').digest('hex')); }); test('key file: made once, never silently replaced', async () => { const dir = tmp(); const cfg = path.join(dir, 's3d.yml'); const a = fileSecrets(cfg); const k1 = await a.encryptionKey(); assert.deepEqual(await fileSecrets(cfg).encryptionKey(), k1, 'same key on the next start'); fs.writeFileSync(a.file, '{"v":1,"secret":"trunc'); await assert.rejects(fileSecrets(cfg).encryptionKey(), /damaged/); assert.match(fs.readFileSync(a.file, 'utf8'), /trunc/, 'the damaged file is left for the user to restore'); const b = fileSecrets(cfg); b.importSecret({ secret: crypto.randomBytes(32).toString('base64') }); assert.ok(fs.readdirSync(dir).some((n) => n.startsWith('pithos-hosted-secret.replaced-')), 'the replaced key is kept'); }); // A fake s3d that pages listings, to check the merge of plain and encrypted. function fakeInner(keys, pageSize) { const sorted = [...keys].sort(); return { async listObjects(bucket, { prefix = '', delimiter = '/', token }) { const all = sorted.filter((k) => k.startsWith(prefix)); const start = token ? Number(token) : 0; const page = all.slice(start, start + pageSize); void delimiter; return { folders: [], objects: page.map((k) => ({ key: k, size: 100, modified: '', etag: '' })), truncated: start + pageSize < all.length, nextToken: String(start + pageSize) }; }, }; } test('listing pages never repeat a source that ran out', async () => { const master = crypto.randomBytes(32); const dk = driveKeys(master, 'd'); const plain = Array.from({ length: 7 }, (_, i) => `f/plain${i}`); const enc = [encryptPath(dk, 'f/secret1')]; const c = new EncryptingS3Client({ endpoint: 'http://127.0.0.1:1', accessKeyId: 'a', secretKey: 'b', master: async () => master }); c.inner = fakeInner([...plain, ...enc], 2); const seen = []; let token; do { const page = await c.listObjects('d', { prefix: 'f/', delimiter: '', token }); seen.push(...page.objects.map((o) => o.key)); token = page.truncated ? page.nextToken : null; } while (token); assert.equal(seen.filter((k) => k === 'f/secret1').length, 1); assert.equal(seen.length, 8); });