@termwright/conformance 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +1129 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/fixtures/adversarial-peer.mjs +567 -0
- package/src/fixtures/generic-app.mjs +236 -0
- package/src/fixtures/ink-probe-app.mjs +23 -0
- package/src/fixtures/prompt-app.mjs +97 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial peer — origin spec §20.3.
|
|
3
|
+
*
|
|
4
|
+
* A raw socket client that speaks the wire protocol by hand: it imports
|
|
5
|
+
* `node:net` and `node:crypto` and **nothing from termwright**. That is the
|
|
6
|
+
* point. If this fixture used `encodeFrame`/`encodeMarker` it could only ever
|
|
7
|
+
* produce traffic the implementation already believes in; re-deriving the
|
|
8
|
+
* framing and the marker MAC from the specification is what makes a mismatch
|
|
9
|
+
* between spec and implementation visible.
|
|
10
|
+
*
|
|
11
|
+
* Protocol of the fixture itself, so suites stay deterministic:
|
|
12
|
+
*
|
|
13
|
+
* 1. it connects, completes a normal handshake and publishes a valid
|
|
14
|
+
* revision 1 (tree + marker), then prints `PEER READY <scenario>`;
|
|
15
|
+
* 2. on the key `g` it performs the hostile act and prints `PEER FIRED`;
|
|
16
|
+
* 3. on `q` it exits 0.
|
|
17
|
+
*
|
|
18
|
+
* Scenarios that attack the handshake itself (`bad-token`, `bad-version`,
|
|
19
|
+
* `no-hello`) skip step 1 and fire on connect.
|
|
20
|
+
*
|
|
21
|
+
* Usage: `node adversarial-peer.mjs <scenario>`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { connect } from 'node:net';
|
|
25
|
+
import { createHmac } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
const scenario = process.argv[2] ?? 'none';
|
|
28
|
+
/**
|
|
29
|
+
* Delays the hello, so a suite can place the handshake inside or outside the
|
|
30
|
+
* driver's late-attach grace. A child that boots slower than the negotiation
|
|
31
|
+
* window is routine; one that boots slower than the grace is not, and the two
|
|
32
|
+
* must land differently.
|
|
33
|
+
*/
|
|
34
|
+
const delayArg = process.argv.find((argument) => argument.startsWith('--hello-delay='));
|
|
35
|
+
const helloDelayMs = delayArg === undefined ? 0 : Number(delayArg.slice('--hello-delay='.length));
|
|
36
|
+
/**
|
|
37
|
+
* Holds every socket write back by a fixed lag, so the pty stream overtakes it.
|
|
38
|
+
*
|
|
39
|
+
* A unix socket hands the driver everything this peer wrote long before the pty
|
|
40
|
+
* has re-encoded a single byte, which hides any test that waits for a line of
|
|
41
|
+
* output and then reads state only a socket frame can carry. Where the socket
|
|
42
|
+
* is slower than the terminal — Windows named pipes, which neither coalesce
|
|
43
|
+
* writes nor deliver them on the pty's schedule — that test asserts on
|
|
44
|
+
* something still in flight. The flag makes the same ordering reproducible on
|
|
45
|
+
* any platform. The lag is constant rather than per-write, so a flood of two
|
|
46
|
+
* hundred revisions still finishes inside the suite's budget.
|
|
47
|
+
*/
|
|
48
|
+
const lagArg = process.argv.find((argument) => argument.startsWith('--socket-lag='));
|
|
49
|
+
const socketLagMs = lagArg === undefined ? 0 : Number(lagArg.slice('--socket-lag='.length));
|
|
50
|
+
/**
|
|
51
|
+
* The mirror image: throttles the *terminal* stream instead of the socket.
|
|
52
|
+
*
|
|
53
|
+
* Markers ride stdout and trees ride the socket, so a terminal slower than the
|
|
54
|
+
* socket means a revision's marker can arrive after the driver has given up
|
|
55
|
+
* waiting for it. A ceiling on bytes per second — rather than a fixed delay —
|
|
56
|
+
* is the faithful model of a pty that re-encodes everything: under a flood a
|
|
57
|
+
* backlog builds and markers fall seconds behind their trees, and once the
|
|
58
|
+
* flood stops the backlog drains and ordinary pairing should resume. Whether it
|
|
59
|
+
* does is the whole question.
|
|
60
|
+
*/
|
|
61
|
+
const bpsArg = process.argv.find((argument) => argument.startsWith('--stdout-bps='));
|
|
62
|
+
const stdoutBps = bpsArg === undefined ? 0 : Number(bpsArg.slice('--stdout-bps='.length));
|
|
63
|
+
/**
|
|
64
|
+
* Bytes of screen repaint to emit before each revision's marker.
|
|
65
|
+
*
|
|
66
|
+
* The other half of the same story, and the half that is the driver's own: a
|
|
67
|
+
* platform which repaints the whole screen per frame — ConPTY — hands the
|
|
68
|
+
* driver far more bytes than the application wrote, and the driver's emulator
|
|
69
|
+
* queue, not the transport, is what falls behind. A marker sitting in that
|
|
70
|
+
* queue is one the driver has received but not yet read, which is why a half's
|
|
71
|
+
* expiry clock starts at the drain barrier rather than on arrival. This knob
|
|
72
|
+
* makes that backlog on any platform.
|
|
73
|
+
*/
|
|
74
|
+
const repaintArg = process.argv.find((argument) => argument.startsWith('--repaint='));
|
|
75
|
+
const repaintBytes = repaintArg === undefined ? 0 : Number(repaintArg.slice('--repaint='.length));
|
|
76
|
+
const REPAINT = repaintBytes > 0 ? `\x1b[H${'.'.repeat(repaintBytes)}\r` : '';
|
|
77
|
+
const endpoint = process.env['TERMWRIGHT_ENDPOINT'];
|
|
78
|
+
const token = process.env['TERMWRIGHT_TOKEN'];
|
|
79
|
+
|
|
80
|
+
/** Ceilings from the protocol's DEFAULT_LIMITS, restated rather than imported. */
|
|
81
|
+
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
82
|
+
const MAX_NODES = 5_000;
|
|
83
|
+
const MAX_DEPTH = 64;
|
|
84
|
+
const MAX_LOG_RECORD_BYTES = 32 * 1024;
|
|
85
|
+
|
|
86
|
+
/** Scenarios that need the log channel negotiated in the handshake. */
|
|
87
|
+
const NEEDS_LOGS = new Set(['log-seq-duplicate', 'log-seq-gap', 'log-oversized', 'log-flood']);
|
|
88
|
+
|
|
89
|
+
/** Scenarios that announce `tree-diffs` and push deltas instead of whole trees. */
|
|
90
|
+
const NEEDS_DELTAS = new Set([
|
|
91
|
+
'delta-sequence',
|
|
92
|
+
'delta-bad-base',
|
|
93
|
+
'delta-cursor-clear',
|
|
94
|
+
'delta-flood',
|
|
95
|
+
'delta-removed-missing',
|
|
96
|
+
'delta-before-snapshot',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
let logSeq = 0;
|
|
100
|
+
let logBudget = null;
|
|
101
|
+
|
|
102
|
+
let sessionId = null;
|
|
103
|
+
let socket = null;
|
|
104
|
+
let published = 0;
|
|
105
|
+
|
|
106
|
+
if (stdoutBps > 0) {
|
|
107
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
108
|
+
const TICK_MS = 50;
|
|
109
|
+
const perTick = Math.max(1, Math.floor((stdoutBps * TICK_MS) / 1000));
|
|
110
|
+
let pending = '';
|
|
111
|
+
setInterval(() => {
|
|
112
|
+
if (pending.length === 0) return;
|
|
113
|
+
write(pending.slice(0, perTick));
|
|
114
|
+
pending = pending.slice(perTick);
|
|
115
|
+
}, TICK_MS);
|
|
116
|
+
process.stdout.write = (chunk) => {
|
|
117
|
+
pending += chunk;
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const say = (line) => process.stdout.write(`${line}\r\n`);
|
|
123
|
+
|
|
124
|
+
/** 4-byte big-endian length prefix + UTF-8 JSON. */
|
|
125
|
+
function frame(value) {
|
|
126
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8');
|
|
127
|
+
const header = Buffer.alloc(4);
|
|
128
|
+
header.writeUInt32BE(body.length, 0);
|
|
129
|
+
return Buffer.concat([header, body]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `OSC 8487 ; twm;{revision};{mac}` closed by BEL, or by ST when asked.
|
|
134
|
+
*
|
|
135
|
+
* MAC = base64url(HMAC-SHA256(token, `${sessionId}:${revision}`))[0..16). Both
|
|
136
|
+
* terminators are legal, so the peer can emit either: a receiver that only
|
|
137
|
+
* accepted the one an implementation happens to write would reject a
|
|
138
|
+
* conforming adapter, and that is a rule worth exercising from the outside.
|
|
139
|
+
*/
|
|
140
|
+
function marker(revision, forSession = sessionId, terminator = '\x07') {
|
|
141
|
+
const mac = createHmac('sha256', token)
|
|
142
|
+
.update(`${forSession}:${revision}`, 'utf8')
|
|
143
|
+
.digest()
|
|
144
|
+
.subarray(0, 16)
|
|
145
|
+
.toString('base64url');
|
|
146
|
+
return `\x1b]8487;twm;${revision};${mac}${terminator}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tree(revision, nodes, overrides = {}) {
|
|
150
|
+
return {
|
|
151
|
+
v: 1,
|
|
152
|
+
sessionId,
|
|
153
|
+
revision,
|
|
154
|
+
columns: 80,
|
|
155
|
+
rows: 24,
|
|
156
|
+
rootIds: ['n1'],
|
|
157
|
+
nodes,
|
|
158
|
+
...overrides,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validNodes(label) {
|
|
163
|
+
return [
|
|
164
|
+
{ id: 'n1', role: 'region', name: 'Peer', bounds: { row: 0, column: 0, width: 20, height: 2 } },
|
|
165
|
+
{
|
|
166
|
+
id: 'n2',
|
|
167
|
+
parentId: 'n1',
|
|
168
|
+
role: 'button',
|
|
169
|
+
name: label,
|
|
170
|
+
testId: 'peer-button',
|
|
171
|
+
bounds: { row: 1, column: 0, width: 10, height: 1 },
|
|
172
|
+
actions: ['activate', 'focus'],
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Publishes a well-formed revision: snapshot, commit, then the render marker. */
|
|
178
|
+
function publish(revision, label = 'Peer') {
|
|
179
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(revision, validNodes(label)) }));
|
|
180
|
+
socket.write(frame({ type: 'revision-commit', revision }));
|
|
181
|
+
process.stdout.write(REPAINT + marker(revision));
|
|
182
|
+
published = revision;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const SCENARIOS = {
|
|
186
|
+
'bad-token': () => {
|
|
187
|
+
socket.write(frame(hello({ token: 'not-the-token' })));
|
|
188
|
+
},
|
|
189
|
+
'bad-version': () => {
|
|
190
|
+
socket.write(frame(hello({ protocol: 'termwright/99' })));
|
|
191
|
+
},
|
|
192
|
+
'no-hello': () => {
|
|
193
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(1, validNodes('Peer')) }));
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
'duplicate-hello': () => {
|
|
197
|
+
socket.write(frame(hello()));
|
|
198
|
+
},
|
|
199
|
+
'oversized-frame': () => {
|
|
200
|
+
// A header claiming eight megabytes, with no body behind it: the ceiling
|
|
201
|
+
// must be enforced on the declared length, before a byte is buffered.
|
|
202
|
+
const header = Buffer.alloc(4);
|
|
203
|
+
header.writeUInt32BE(MAX_FRAME_BYTES * 8, 0);
|
|
204
|
+
socket.write(header);
|
|
205
|
+
},
|
|
206
|
+
'partial-frame': () => {
|
|
207
|
+
const header = Buffer.alloc(4);
|
|
208
|
+
header.writeUInt32BE(4096, 0);
|
|
209
|
+
socket.write(Buffer.concat([header, Buffer.from('{"type":"sna', 'utf8')]));
|
|
210
|
+
},
|
|
211
|
+
'duplicate-frames': () => {
|
|
212
|
+
const message = frame({ type: 'snapshot', snapshot: tree(2, validNodes('Twice')) });
|
|
213
|
+
socket.write(message);
|
|
214
|
+
socket.write(message);
|
|
215
|
+
process.stdout.write(marker(2));
|
|
216
|
+
},
|
|
217
|
+
cycle: () => {
|
|
218
|
+
send(
|
|
219
|
+
tree(2, [
|
|
220
|
+
{ id: 'n1', role: 'region', name: 'Peer' },
|
|
221
|
+
{ id: 'n2', parentId: 'n3', role: 'button', name: 'A' },
|
|
222
|
+
{ id: 'n3', parentId: 'n2', role: 'button', name: 'B' },
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
'missing-parent': () => {
|
|
227
|
+
send(
|
|
228
|
+
tree(2, [
|
|
229
|
+
{ id: 'n1', role: 'region', name: 'Peer' },
|
|
230
|
+
{ id: 'n2', parentId: 'ghost', role: 'button', name: 'Orphan' },
|
|
231
|
+
]),
|
|
232
|
+
);
|
|
233
|
+
},
|
|
234
|
+
'impossible-bounds': () => {
|
|
235
|
+
send(
|
|
236
|
+
tree(2, [
|
|
237
|
+
{
|
|
238
|
+
id: 'n1',
|
|
239
|
+
role: 'region',
|
|
240
|
+
name: 'Peer',
|
|
241
|
+
bounds: { row: 9_000, column: 9_000, width: 5, height: 5 },
|
|
242
|
+
},
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
},
|
|
246
|
+
'decreasing-revision': () => {
|
|
247
|
+
publish(3, 'Third');
|
|
248
|
+
setTimeout(() => publish(2, 'Second'), 50);
|
|
249
|
+
},
|
|
250
|
+
'marker-without-tree': () => {
|
|
251
|
+
process.stdout.write(marker(5));
|
|
252
|
+
},
|
|
253
|
+
'tree-without-marker': () => {
|
|
254
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(4, validNodes('Unpaired')) }));
|
|
255
|
+
socket.write(frame({ type: 'revision-commit', revision: 4 }));
|
|
256
|
+
},
|
|
257
|
+
'rapid-rerender': () => {
|
|
258
|
+
for (let revision = 2; revision <= 200; revision += 1) publish(revision, `Rev${revision}`);
|
|
259
|
+
},
|
|
260
|
+
flood: () => {
|
|
261
|
+
const noise = `${'x'.repeat(4096)}\r\n`;
|
|
262
|
+
for (let chunk = 0; chunk < 512; chunk += 1) process.stdout.write(noise);
|
|
263
|
+
for (let revision = 2; revision <= 100; revision += 1) {
|
|
264
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(revision, validNodes(`Flood${revision}`)) }));
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
'disconnect-mid-render': () => {
|
|
268
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Torn')) }));
|
|
269
|
+
socket.destroy();
|
|
270
|
+
},
|
|
271
|
+
'hostile-unicode': () => {
|
|
272
|
+
// A lone high surrogate survives JSON.stringify as an escape, so the bytes
|
|
273
|
+
// on the wire are ASCII and the receiver is the one that has to fail closed.
|
|
274
|
+
send(tree(2, [{ id: 'n1', role: 'region', name: 'lone \ud800 surrogate' }]));
|
|
275
|
+
},
|
|
276
|
+
'foreign-session': () => {
|
|
277
|
+
send(tree(2, validNodes('Foreign'), { sessionId: 'someone-elses-session' }));
|
|
278
|
+
},
|
|
279
|
+
'unknown-message': () => {
|
|
280
|
+
socket.write(frame({ type: 'take-over-the-terminal', payload: 'please' }));
|
|
281
|
+
},
|
|
282
|
+
'not-json': () => {
|
|
283
|
+
const body = Buffer.from('this is not json at all', 'utf8');
|
|
284
|
+
const header = Buffer.alloc(4);
|
|
285
|
+
header.writeUInt32BE(body.length, 0);
|
|
286
|
+
socket.write(Buffer.concat([header, body]));
|
|
287
|
+
},
|
|
288
|
+
'deep-nesting': () => {
|
|
289
|
+
let value = 'leaf';
|
|
290
|
+
for (let depth = 0; depth < MAX_DEPTH * 2; depth += 1) value = { nested: value };
|
|
291
|
+
socket.write(frame({ type: 'snapshot', snapshot: value }));
|
|
292
|
+
},
|
|
293
|
+
'too-many-nodes': () => {
|
|
294
|
+
const nodes = [{ id: 'n1', role: 'region', name: 'Peer' }];
|
|
295
|
+
for (let index = 2; index <= MAX_NODES + 1_000; index += 1) {
|
|
296
|
+
nodes.push({ id: `n${index}`, parentId: 'n1', role: 'text', name: `t${index}` });
|
|
297
|
+
}
|
|
298
|
+
send(tree(2, nodes));
|
|
299
|
+
},
|
|
300
|
+
'second-connection': () => {
|
|
301
|
+
// One adapter per session. A second channel from the same process is the
|
|
302
|
+
// benign shape of that mistake; the driver must refuse it and say so
|
|
303
|
+
// without disturbing the adapter that is already attached.
|
|
304
|
+
const second = connect(endpoint, () => {
|
|
305
|
+
second.write(frame(hello()));
|
|
306
|
+
});
|
|
307
|
+
second.on('error', () => say('PEER SECOND SOCKET ERROR'));
|
|
308
|
+
second.on('close', () => say('PEER SECOND SOCKET CLOSED'));
|
|
309
|
+
// Buffered and length-decoded, not regexed out of one chunk: the reply and
|
|
310
|
+
// the socket's own destruction race, and a split read would silently lose
|
|
311
|
+
// the error this scenario exists to observe.
|
|
312
|
+
let rest = Buffer.alloc(0);
|
|
313
|
+
second.on('data', (chunk) => {
|
|
314
|
+
rest = Buffer.concat([rest, chunk]);
|
|
315
|
+
for (;;) {
|
|
316
|
+
if (rest.length < 4) break;
|
|
317
|
+
const length = rest.readUInt32BE(0);
|
|
318
|
+
if (rest.length < 4 + length) break;
|
|
319
|
+
const message = JSON.parse(rest.subarray(4, 4 + length).toString('utf8'));
|
|
320
|
+
rest = rest.subarray(4 + length);
|
|
321
|
+
if (message.type === 'error') say(`PEER SECOND GOT ERROR ${message.code}`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
'log-no-negotiation': () => {
|
|
326
|
+
// The handshake never announced `logs`, so the budget was never granted.
|
|
327
|
+
socket.write(frame({ type: 'log', record: logRecord(1, 'uninvited') }));
|
|
328
|
+
},
|
|
329
|
+
'log-seq-duplicate': () => {
|
|
330
|
+
sendLog(1, 'first');
|
|
331
|
+
sendLog(1, 'same seq again');
|
|
332
|
+
sendLog(2, 'after the duplicate');
|
|
333
|
+
},
|
|
334
|
+
'log-seq-gap': () => {
|
|
335
|
+
sendLog(1, 'before the gap');
|
|
336
|
+
// Four records the adapter dropped at the source; the gap is how it says so.
|
|
337
|
+
sendLog(6, 'after the gap');
|
|
338
|
+
},
|
|
339
|
+
'log-oversized': () => {
|
|
340
|
+
socket.write(
|
|
341
|
+
frame({ type: 'log', record: logRecord(1, 'x'.repeat(MAX_LOG_RECORD_BYTES + 1024)) }),
|
|
342
|
+
);
|
|
343
|
+
},
|
|
344
|
+
'log-flood': () => {
|
|
345
|
+
// Far past any sane per-second budget, sent in one turn.
|
|
346
|
+
for (let seq = 1; seq <= 500; seq += 1) sendLog(seq, `flood ${seq}`);
|
|
347
|
+
},
|
|
348
|
+
'delta-sequence': () => {
|
|
349
|
+
sendDelta(renameDelta(1, 2, 'Second'));
|
|
350
|
+
sendDelta(renameDelta(2, 3, 'Third'));
|
|
351
|
+
sendDelta(renameDelta(3, 4, 'Fourth'));
|
|
352
|
+
},
|
|
353
|
+
'delta-bad-base': () => {
|
|
354
|
+
// Base 999 was never held, so this cannot be patched onto anything.
|
|
355
|
+
sendDelta(renameDelta(999, 1000, 'Impossible'));
|
|
356
|
+
},
|
|
357
|
+
'delta-cursor-clear': () => {
|
|
358
|
+
// A delta can set a cursor but never clear it. `c` sends the full tree that
|
|
359
|
+
// clears it, so the two halves are separate steps rather than a race.
|
|
360
|
+
sendDelta(renameDelta(1, 2, 'Cursor', { cursor: { row: 3, column: 7, visible: true } }));
|
|
361
|
+
},
|
|
362
|
+
'delta-flood': () => {
|
|
363
|
+
for (let revision = 2; revision <= 200; revision += 1) {
|
|
364
|
+
sendDelta(renameDelta(revision - 1, revision, `Rev${revision}`));
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
'delta-removed-missing': () => {
|
|
368
|
+
sendDelta({ baseRevision: 1, revision: 2, changed: [], removed: ['ghost'] });
|
|
369
|
+
},
|
|
370
|
+
'delta-before-snapshot': () => {
|
|
371
|
+
// Handled specially at handshake time: nothing was published first.
|
|
372
|
+
sendDelta(renameDelta(1, 2, 'Premature'));
|
|
373
|
+
},
|
|
374
|
+
'marker-st-terminator': () => {
|
|
375
|
+
// ST rather than BEL. The tree must pair exactly as it does with BEL.
|
|
376
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Terminated')) }));
|
|
377
|
+
process.stdout.write(marker(2, sessionId, '\x1b\\'));
|
|
378
|
+
published = 2;
|
|
379
|
+
},
|
|
380
|
+
'peer-error': () => {
|
|
381
|
+
// The other direction: the adapter reports a protocol error at us. The
|
|
382
|
+
// driver must surface the code the peer chose, not one of its own.
|
|
383
|
+
socket.write(frame({ type: 'error', code: 'internal', message: 'the adapter gave up' }));
|
|
384
|
+
},
|
|
385
|
+
'foreign-marker': () => {
|
|
386
|
+
// A marker MAC bound to a different session must not commit anything here.
|
|
387
|
+
socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Forged')) }));
|
|
388
|
+
process.stdout.write(marker(2, 'another-session'));
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
function hello(overrides = {}) {
|
|
393
|
+
const capabilities = ['tree', 'bounds', 'states', 'actions', 'render-revisions'];
|
|
394
|
+
// `log-no-negotiation` deliberately does NOT announce it: the point of that
|
|
395
|
+
// scenario is sending records the driver never invited.
|
|
396
|
+
if (NEEDS_LOGS.has(scenario)) capabilities.push('logs');
|
|
397
|
+
if (NEEDS_DELTAS.has(scenario)) capabilities.push('tree-diffs');
|
|
398
|
+
return {
|
|
399
|
+
type: 'hello',
|
|
400
|
+
protocol: 'termwright/1',
|
|
401
|
+
token,
|
|
402
|
+
adapter: { name: 'adversarial-peer', version: '0.1.0' },
|
|
403
|
+
capabilities,
|
|
404
|
+
...overrides,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** One well-formed record, with the seq the caller asks for. */
|
|
409
|
+
function logRecord(seq, message = `record ${seq}`) {
|
|
410
|
+
return { ts: Date.now(), level: 'info', message, logger: 'peer', seq };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sendLog(seq, message) {
|
|
414
|
+
socket.write(frame({ type: 'log', record: logRecord(seq, message) }));
|
|
415
|
+
logSeq = Math.max(logSeq, seq);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Sends a delta and its marker. The peer keeps no model of its own beyond the
|
|
420
|
+
* revision counter: composing is the receiver's job, and a producer that also
|
|
421
|
+
* composed would only prove it agrees with itself.
|
|
422
|
+
*/
|
|
423
|
+
function sendDelta(delta) {
|
|
424
|
+
// The delta is the message: the body sits beside the discriminator rather
|
|
425
|
+
// than nested under it.
|
|
426
|
+
socket.write(frame({ type: 'tree-delta', ...delta }));
|
|
427
|
+
process.stdout.write(REPAINT + marker(delta.revision));
|
|
428
|
+
published = Math.max(published, delta.revision);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** A delta that renames the button, so a composed tree is observable on screen. */
|
|
432
|
+
function renameDelta(baseRevision, revision, label, overrides = {}) {
|
|
433
|
+
return {
|
|
434
|
+
baseRevision,
|
|
435
|
+
revision,
|
|
436
|
+
changed: [
|
|
437
|
+
{
|
|
438
|
+
id: 'n2',
|
|
439
|
+
parentId: 'n1',
|
|
440
|
+
role: 'button',
|
|
441
|
+
name: label,
|
|
442
|
+
testId: 'peer-button',
|
|
443
|
+
bounds: { row: 1, column: 0, width: 10, height: 1 },
|
|
444
|
+
actions: ['activate', 'focus'],
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
removed: [],
|
|
448
|
+
...overrides,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Sends a snapshot together with its marker, so only the tree can be at fault. */
|
|
453
|
+
function send(snapshot) {
|
|
454
|
+
socket.write(frame({ type: 'snapshot', snapshot }));
|
|
455
|
+
process.stdout.write(marker(snapshot.revision));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const ATTACKS_HANDSHAKE = new Set(['bad-token', 'bad-version', 'no-hello']);
|
|
459
|
+
|
|
460
|
+
function fire() {
|
|
461
|
+
const attack = SCENARIOS[scenario];
|
|
462
|
+
if (attack === undefined) {
|
|
463
|
+
say(`PEER UNKNOWN SCENARIO ${scenario}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
attack();
|
|
468
|
+
say('PEER FIRED');
|
|
469
|
+
} catch (error) {
|
|
470
|
+
// A peer that cannot even build its attack must say so rather than die
|
|
471
|
+
// silently and leave the suite waiting on a timeout.
|
|
472
|
+
say(`PEER FAILED ${String(error && error.message ? error.message : error)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
process.stdin.setRawMode?.(true);
|
|
477
|
+
process.stdin.resume();
|
|
478
|
+
process.stdin.on('data', (chunk) => {
|
|
479
|
+
const text = chunk.toString('utf8');
|
|
480
|
+
if (text.includes('q')) {
|
|
481
|
+
say('BYE');
|
|
482
|
+
process.exit(0);
|
|
483
|
+
}
|
|
484
|
+
if (text.includes('g')) fire();
|
|
485
|
+
if (text.includes('p')) publish(published + 1, `Manual${published + 1}`);
|
|
486
|
+
// The cursor-clearing half of `delta-cursor-clear`: only a full tree can do
|
|
487
|
+
// it, and `tree()` builds one without a cursor.
|
|
488
|
+
if (text.includes('c')) publish(published + 1, 'NoCursor');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
say(`PEER START ${scenario}`);
|
|
492
|
+
|
|
493
|
+
if (endpoint === undefined || token === undefined) {
|
|
494
|
+
say('PEER DORMANT');
|
|
495
|
+
} else {
|
|
496
|
+
socket = connect(endpoint, () => {
|
|
497
|
+
if (socketLagMs > 0) {
|
|
498
|
+
const write = socket.write.bind(socket);
|
|
499
|
+
const held = [];
|
|
500
|
+
let scheduled = false;
|
|
501
|
+
socket.write = (chunk) => {
|
|
502
|
+
held.push(chunk);
|
|
503
|
+
if (!scheduled) {
|
|
504
|
+
scheduled = true;
|
|
505
|
+
setTimeout(() => {
|
|
506
|
+
scheduled = false;
|
|
507
|
+
while (held.length > 0) write(held.shift());
|
|
508
|
+
}, socketLagMs);
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (ATTACKS_HANDSHAKE.has(scenario)) {
|
|
514
|
+
fire();
|
|
515
|
+
say(`PEER READY ${scenario}`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (helloDelayMs > 0) {
|
|
519
|
+
say(`PEER DELAYING HELLO ${helloDelayMs}`);
|
|
520
|
+
setTimeout(() => {
|
|
521
|
+
socket.write(frame(hello()));
|
|
522
|
+
say('PEER SENT HELLO');
|
|
523
|
+
}, helloDelayMs);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
socket.write(frame(hello()));
|
|
527
|
+
});
|
|
528
|
+
socket.on('error', () => say('PEER SOCKET ERROR'));
|
|
529
|
+
socket.on('close', () => say('PEER SOCKET CLOSED'));
|
|
530
|
+
|
|
531
|
+
let pending = Buffer.alloc(0);
|
|
532
|
+
socket.on('data', (chunk) => {
|
|
533
|
+
pending = Buffer.concat([pending, chunk]);
|
|
534
|
+
for (;;) {
|
|
535
|
+
if (pending.length < 4) break;
|
|
536
|
+
const length = pending.readUInt32BE(0);
|
|
537
|
+
if (pending.length < 4 + length) break;
|
|
538
|
+
const message = JSON.parse(pending.subarray(4, 4 + length).toString('utf8'));
|
|
539
|
+
pending = pending.subarray(4 + length);
|
|
540
|
+
if (message.type === 'hello-ack') {
|
|
541
|
+
sessionId = message.sessionId;
|
|
542
|
+
logBudget = message.logs ?? null;
|
|
543
|
+
say(`PEER LOGS ${logBudget === null ? 'denied' : `enabled ${logBudget.maxRecordsPerSecond}/s`}`);
|
|
544
|
+
// `delta-before-snapshot` deliberately skips the opening tree: its
|
|
545
|
+
// whole point is a delta with nothing to compose onto.
|
|
546
|
+
if (scenario !== 'delta-before-snapshot') publish(1);
|
|
547
|
+
say(`PEER READY ${scenario}`);
|
|
548
|
+
}
|
|
549
|
+
if (message.type === 'get-tree') {
|
|
550
|
+
// Answering is what makes a resync observable end to end: the driver
|
|
551
|
+
// asks, the peer supplies, the session returns to a known tree.
|
|
552
|
+
const revision = published + 1;
|
|
553
|
+
socket.write(
|
|
554
|
+
frame({
|
|
555
|
+
type: 'get-tree-result',
|
|
556
|
+
requestId: message.requestId,
|
|
557
|
+
snapshot: tree(revision, validNodes('Resynced')),
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
published = revision;
|
|
561
|
+
process.stdout.write(marker(revision));
|
|
562
|
+
say(`PEER SENT FULL TREE ${revision}`);
|
|
563
|
+
}
|
|
564
|
+
if (message.type === 'error') say(`PEER GOT ERROR ${message.code}`);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|