summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs
blob: 60ec48a70f015d7e46fafd96a86c89c8de9dcb9d (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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
/**
 * Does the browser's real `uploadFile` produce frames a real node can open —
 * and does it read back what that node actually answered?
 *
 * Drives **the real `MeshBayTransport` over the real `crypto.js`**. Only the DOM
 * and the DataChannel are stand-ins; the msgpack encode, the HKDF, the AES-GCM
 * and the whole upload loop are the shipped code.
 *
 * It exists because a source-reading test cannot show either half of MNP 2.0's
 * upload. `test_transport_contracts` can see that `sealGroup` is called; only
 * this can show that what comes out opens under `meshbay_common.groupbox` — and
 * that the caller of `uploadFile` is told the name the *node* chose, which now
 * arrives sealed and would otherwise be `undefined` with nothing to notice it.
 *
 *   node upload_seal_probe.mjs <static-dir> <input.json>
 *
 * Two modes, because the node half runs in Python between them:
 *   "send"    — run the upload, print every frame it emits, answer nothing
 *   "receive" — run it again, answering with acks Python built. Their
 *               `upload_id` is retargeted to this run's; it is outside the
 *               seal, so the ciphertext stays exactly the one Python produced.
 */
import fs from 'fs';
import { delimiter } from 'path';

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

for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
  console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
}

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)();
// The msgpack codec is private to transport.js — pulled out the same way the
// groupbox parity harness pulls out sealGroup, so this probe encodes and
// decodes with the codec that is actually shipped rather than a second one.
const { msgpack_encode, msgpack_decode } =
  new Function(transportSrc + '\nreturn { msgpack_encode, msgpack_decode };')();

const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
const toHex = (u8) =>
  Array.from(u8).map((b) => b.toString(16).padStart(2, '0')).join('');

// Just enough of a File: a name, a size, and slices that yield ArrayBuffers.
const bytes = hex(input.file.data);
const file = {
  name: input.file.name,
  size: bytes.length,
  slice(a, b) {
    const part = bytes.slice(a, b);
    return { arrayBuffer: async () => part.buffer.slice(
      part.byteOffset, part.byteOffset + part.byteLength) };
  },
};

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

const frames = [];
let uploadId = null;
let answered = 0;
tp._send = (msg) => {
  frames.push(toHex(msgpack_encode(msg)));
  if (msg.upload_id) uploadId = msg.upload_id;
  if (input.mode !== 'receive') {
    // Nothing answers in this mode -- except the probe, which the client waits
    // five seconds for. A node that predates it refuses the index, and that
    // refusal is a plain error rather than a sealed ack, so the harness can
    // produce it honestly. It is also the degradation path worth exercising.
    if (msg.chunk_index === -1) {
      // With `probe_ack`, answer it the way a node holding part of this file
      // does; without, the way one that predates the probe does.
      const reply = input.probe_ack
        ? Object.assign(msgpack_decode(hex(input.probe_ack)),
                        { upload_id: uploadId })
        : { type: 'error', upload_id: uploadId,
            code: 'bad_chunk_index', detail: 'Unexpected chunk index' };
      setImmediate(() => tp._dispatch(reply));
    }
    return;
  }
  // Answer as the node did, on the next turn of the loop so the send path
  // finishes first — which is also how a real ack arrives.
  //
  // By position, not by `chunk_index`: the node answers every frame including
  // the probe, whose index is -1, and the two lists are built from the same
  // sequence of frames.
  const ack = msgpack_decode(hex(input.acks[answered++]));
  ack.upload_id = uploadId;
  // Through the real `_dispatch`, so the routing under test — matching an
  // ack to its uploader by `upload_id` — is the shipped one.
  setImmediate(() => tp._dispatch(ack));
};

const out = { frames, mode: input.mode };
const done = tp.uploadFile(file, { chunkSize: input.chunk_size,
                                   dir: input.dir, root: input.root });

if (input.mode === 'receive') {
  done.then((stored) => { out.state = 'resolved'; out.stored = stored; })
      .catch((e) => { out.state = 'rejected'; out.message = e.message; })
      .finally(() => { out.upload_id = uploadId;
                       process.stdout.write(JSON.stringify(out)); });
} else {
  // Nothing will answer, so let the send loop run itself out and report.
  done.catch((e) => { out.state = 'rejected'; out.message = e.message; });
  setTimeout(() => { out.upload_id = uploadId;
                     process.stdout.write(JSON.stringify(out)); }, 250);
}