summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
blob: 2493a055b4d22193f57956b1f79b8c73ddf9ffee (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/**
 * Does the browser open a sealed index — and does it stop when it cannot?
 *
 * Drives **the real `MeshBayTransport` over the real `crypto.js`**, fed real
 * length-prefixed msgpack frames built by Python's `groupbox.seal`. Only the DOM
 * and the DataChannel are stand-ins; the framing, the msgpack decode, the
 * dispatch, the HKDF and the AES-GCM are all the shipped code.
 *
 * It exists because the two things worth knowing here are invisible to a
 * source-reading test. The first is §3.4: a payload that does not open must
 * *raise*, never become an empty index — "this group has no files" is a
 * legitimate state, so a silent fallback is indistinguishable from the truth.
 * The second is ordering: opening is asynchronous while `_dispatch` is not, so
 * two index messages could be applied in whichever order their decrypt promises
 * happened to settle, and a delta applied before its base is a silently wrong
 * view of the group.
 *
 *   node index_seal_probe.mjs <static-dir> <vectors.json>
 *
 * Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how
 * the outstanding request ended.
 */
import fs from 'fs';
import { delimiter } from 'path';

const STATIC = process.argv[2];
const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));

// The transport logs to the console on the paths under test; stdout is this
// probe's JSON result, so everything it says goes to stderr instead.
for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
  console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
}

// Just enough DOM for two classic scripts that expect a page.
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
globalThis.removeEventListener = () => {};
globalThis.location = { hash: '' };
globalThis.document = {
  addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
};

new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
// argv[4]: transport.js and the parts split out of it, joined as one scope.
const transportSrc = process.argv[4].split(delimiter)
  .map((p) => fs.readFileSync(p, 'utf8')).join('\n');
new Function(transportSrc)();

const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));

const events = [];
const tp = new window.MeshBayTransport('', 'token');
tp._connected = true;
tp._channel = { readyState: 'open', send() {}, close() {} };
tp._pc = { close() {} };
tp._gekRaw = hex(input.gek);
tp._connectArgs = { groupId: input.group_id };

tp.onIndexSync = (msg) => events.push({
  event: 'index_sync',
  entries: (msg.entries || []).map((e) => e.name),
  dirs: msg.dirs || [],
  version: msg.version,
  // Present on the message a consumer sees? The envelope's own fields should
  // be gone, and the payload's should have taken their place.
  hasCiphertext: 'ct' in msg || 'nonce' in msg,
});
tp.onIndexDelta = (msg) => events.push({
  event: 'index_delta',
  additions: (msg.additions || []).map((e) => e.name),
  base_version: msg.base_version,
  version: msg.version,
});
tp.onSessionFailed = (err) => events.push({ event: 'session_failed', message: err.message });

// One outstanding fetchIndex, so the probe can say what a *waiting caller* is
// told — which is the half of §3.4 a callback cannot show.
const fetchOutcome = { state: 'pending' };
tp._send = () => {};
tp.fetchIndex()
  .then((msg) => { fetchOutcome.state = 'resolved';
                   fetchOutcome.entries = (msg.entries || []).map((e) => e.name); })
  .catch((e) => { fetchOutcome.state = 'rejected'; fetchOutcome.message = e.message; });

const closed = { count: 0 };
const realClose = tp.close.bind(tp);
tp.close = () => { closed.count += 1; realClose(); };

(async () => {
  // Delivered exactly as the DataChannel delivers them: one call per frame, in
  // order, with no await between.
  for (const frame of input.frames) tp._onMessage(hex(frame).buffer);

  // Let the opening chain drain. Each message costs two WebCrypto promises, so
  // a handful of turns is not enough to be sure; a real delay is.
  await new Promise((r) => setTimeout(r, 200));

  process.stdout.write(JSON.stringify({
    events, fetchIndex: fetchOutcome, closed: closed.count,
  }));
})();