pithos/test/hosted.test.js

104 lines
5.1 KiB
JavaScript
Raw Normal View History

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