Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_share.mjs

36.7 KiB, 1 run

created by r2519314175:675, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_share.mjs — a share that is too big for the relay has somewhere to go.
2//
3// THE DEFECT THIS CLOSES IS A DEAD END, NOT A BUG. `www/js/share.js` composed a
4// Diamond into a signed, sealed envelope and then stopped. The only carrier the
5// app had was the message relay, and `/api/post` refuses a sealed envelope over
6// 64 KiB (`gateway/src/settings.rs`, the `max_bytes` knob on that route). The
7// Log Life capp page alone is about 64 KB, so a share carrying a capp could not
8// go through the relay AT ALL — the feature was unreachable at a byte count, and
9// unreachable silently.
10//
11// So the carrier is now CHOSEN by measuring, and the large case takes a file:
12// extension `.dshare`, type `application/octet-stream`, both directions. The
13// checks below are about that file route, and three of them are about what must
14// NOT change by going through a file:
15//
16// * the bytes are the same sealed envelope the relay would have carried, so a
17// `.dshare` is no more trusted than a message;
18// * the CONSENT STEP is the same one. Data travels freely; code travels only
19// by explicit consent, because a capp is a program somebody else wrote and
20// "open a message" must never become a code-execution path. `take` goes
21// through `receive` → `accept` → `askAboutCode` exactly as the relay does;
22// * a share carrying an image carries the image, byte for byte. `land` cannot
23// yet WRITE one — the store's only door takes text — so it refuses that file
24// by name rather than landing replacement characters nobody would notice
25// until they opened the picture.
26//
27// TO SEE THESE FAIL, break it like this:
28//
29// * `share.js`, `fitsRelay`: return `n <= RELAY_MAX`. The boundary check goes
30// red — a sealed envelope of exactly 64 KiB is refused by the gateway's
31// CHEAP base64-length estimate before it ever decodes anything.
32// * `share.js`, `take`: call `openSealed` and `accept(read, {withCode:true})`
33// instead of `receive`. The consent checks go red and a stranger's page
34// lands without a question, which is the failure the whole design exists to
35// prevent.
36// * `share.js`, `compose`: drop `name` from what it answers. Every saved file
37// is called `share-<addr>.dshare` again and the sender's own name for it
38// reaches nobody.
39//
40// Run: node dev/verify_share.mjs (dev/serve.mjs and dev/mockllm.mjs; no gateway)
41
42import fs from 'node:fs';
43import { open, scratch } from './harness.mjs';
44
45const PROFILE = scratch('pw', 'share');
46
47let pass = 0, fail = 0;
48const check = (ok, name, detail) => {
49 if (ok) { pass++; console.log(' ok ' + name); }
50 else { fail++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
51};
52
53/// A PNG somebody else made: eight bytes of signature and a body that is not
54/// UTF-8 anywhere. It is here because binary is the thing a share is easiest to
55/// get quietly wrong about, and mojibake looks like success.
56const PNG = Buffer.from(
57 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
58 'base64');
59
60const s = await open({ name: 'share', profile: PROFILE, connect: false });
61const { page } = s;
62
63console.log('\n-- this build has a carrier at all --');
64const surface = await page.evaluate(() => {
65 const S = window.DaimondShare;
66 if (!S) return null;
67 return {
68 ready: S.ready(), why: S.why(), ext: S.ext, mime: S.mime,
69 fns: ['carrier', 'carrierWhy', 'fitsRelay', 'save', 'take', 'pick']
70 .filter(n => typeof S[n] === 'function'),
71 limits: S.limits,
72 };
73});
74check(!!surface, 'share.js is on the page', surface ? '' : 'no window.DaimondShare');
75check(!!surface && surface.ready, 'and it can share: format, seal and store are all loaded',
76 surface ? surface.why : 'nothing');
77// The contract fixes both of these. A `.dshare` is CIPHERTEXT, so there is
78// nothing truthful to say about its contents and nothing a browser should try
79// to do with it but save it.
80check(!!surface && surface.ext === '.dshare', 'the file is a .dshare',
81 surface ? surface.ext : '');
82check(!!surface && surface.mime === 'application/octet-stream',
83 'carried as application/octet-stream', surface ? surface.mime : '');
84check(!!surface && surface.fns.length === 6,
85 'and the carrier is reachable: carrier, carrierWhy, fitsRelay, save, take, pick',
86 surface ? surface.fns.join(', ') : 'none');
87
88console.log('\n-- the relay ceiling is the GATEWAY’s number, not one invented here --');
89// THE EXTERNAL SIDE OF THE CLAIM. The client refuses at a size; the gateway is
90// what actually refuses, and a client ceiling that had drifted from the server's
91// would send a share that comes back 413. Read out of lane G's own file.
92const gw = fs.readFileSync(new URL('../gateway/src/settings.rs', import.meta.url), 'utf8');
93const postBlock = gw.slice(gw.indexOf('route: "/api/post"'), gw.indexOf('route: "/api/post"') + 900);
94const fallback = (postBlock.match(/fallback:\s*"(\d+)"/) || [])[1];
95check(fallback === '65536', 'the /api/post max_bytes knob still falls back to 64 KiB',
96 'gateway says ' + fallback);
97check(!!surface && surface.limits.relay === 65536,
98 'and share.js holds the same number', surface ? String(surface.limits.relay) : '');
99
100const fits = await page.evaluate(() => {
101 const f = window.DaimondShare.fitsRelay;
102 return { at64k: f(65536), one_under: f(65535), two_under: f(65534),
103 three_under: f(65533), small: f(555), zero: f(0) };
104});
105// BOTH of the gateway's checks, which are not the same number. `/api/post` turns
106// a body away on the cheap base64-length estimate BEFORE decoding —
107// `envelope.len() / 4 * 3 > max_bytes` — and then again on the decoded length.
108// base64 rounds up to a group of three, so 65,536 bytes becomes 87,384
109// characters and 87384 / 4 * 3 is 65,538: refused. 65,535 is the last size that
110// goes through, and a client that stopped at `<= 65536` would post one that
111// bounces.
112check(fits.at64k === false, 'a sealed envelope of exactly 64 KiB does NOT fit: base64 rounds up',
113 JSON.stringify(fits));
114check(fits.one_under === true, 'and 65,535 bytes is the last size that does',
115 JSON.stringify(fits));
116check(fits.small === true && fits.zero === true, 'small ones fit, obviously',
117 JSON.stringify(fits));
118// Modelled here from the gateway's own arithmetic rather than from share.js, so
119// the two are computed independently and agreeing means something.
120const gateway = (n) => {
121 const chars = Math.ceil(n / 3) * 4; // base64, padded
122 return !(Math.floor(chars / 4) * 3 > 65536) && !(n > 65536);
123};
124let boundaryAgree = true;
125for (const n of [0, 1, 555, 65533, 65534, 65535, 65536, 65537, 200000]) {
126 const mine = await page.evaluate((n) => window.DaimondShare.fitsRelay(n), n);
127 if (mine !== gateway(n)) { boundaryAgree = false; console.log(' disagree at ' + n); }
128}
129check(boundaryAgree, 'and the client agrees with the handler’s arithmetic at every boundary');
130
131console.log('\n-- a small share goes by relay; a capp cannot --');
132const small = await page.evaluate(async () => {
133 const to = await DaimondIdentity.publicKeyRaw();
134 const toEnc = DaimondIdentity.sealingKeyRaw();
135 const made = await DaimondShare.compose({
136 name: 'Recipe book', note: 'here you go', to: to, toEnc: toEnc,
137 files: [{ path: 'notes.md', body: '# Bread\n\nFlour, water, salt.\n' }],
138 });
139 window.__small = made;
140 return { name: made.name, addr: made.addr, sealed: made.sealed.length, code: made.code,
141 carrier: DaimondShare.carrier(made), why: DaimondShare.carrierWhy(made),
142 file: DaimondShare.filename(made) };
143});
144check(small.carrier === 'relay', 'a recipe goes through the relay',
145 small.carrier + ' at ' + small.sealed + ' bytes');
146check(/555|\d+ B/.test(small.why) && /relay/i.test(small.why),
147 'and the sentence says so', small.why);
148// The defect this fixes: `filename` builds the stem from `made.name`, and
149// `compose` did not answer one, so every file anybody ever saved was called
150// `share-<addr>.dshare`.
151check(small.name === 'Recipe book' && /^Recipe-book-/.test(small.file),
152 'the file carries the name the sender chose', small.file);
153check(/\.dshare$/.test(small.file), 'and the extension', small.file);
154check(small.file.indexOf(small.addr.slice(0, 12)) !== -1,
155 'and enough of the address to tell two shares of one Diamond apart', small.file);
156
157const capp = await page.evaluate(async () => {
158 const to = await DaimondIdentity.publicKeyRaw();
159 const toEnc = DaimondIdentity.sealingKeyRaw();
160 // A page of about the size the Log Life capp actually is.
161 let html = '<!doctype html><html><body><div id="log"></div><script>\n';
162 while (html.length < 64 * 1024) html += '// a line of the page nobody reads twice\n';
163 html += '</' + 'script></body></html>';
164 const made = await DaimondShare.compose({
165 name: 'Log Life', to: to, toEnc: toEnc,
166 files: [{ path: 'crystal.html', body: html }],
167 });
168 window.__capp = made;
169 return { sealed: made.sealed.length, code: made.code,
170 carrier: DaimondShare.carrier(made), why: DaimondShare.carrierWhy(made) };
171});
172check(capp.sealed > 65536, 'a capp share really is over the relay’s ceiling',
173 capp.sealed + ' bytes sealed');
174check(capp.carrier === 'file',
175 'so it takes the file route — this is the case that had NO carrier at all', capp.carrier);
176check(capp.code === true, 'and the payload’s own signed claim says it carries code',
177 String(capp.code));
178check(/64\.0 KB/.test(capp.why) && /file/i.test(capp.why),
179 'the sentence names both sizes, because the sender is the only one who can act on it',
180 capp.why);
181
182console.log('\n-- writing one out --');
183const dl = await (async () => {
184 const wait = page.waitForEvent('download', { timeout: 15000 }).catch(() => null);
185 const said = await page.evaluate(() => {
186 const n = DaimondShare.save(window.__small);
187 return { name: n, said: DaimondShare.savedSaid(n) };
188 });
189 const d = await wait;
190 if (!d) return null;
191 const out = scratch('share-out', d.suggestedFilename());
192 await d.saveAs(out);
193 return { name: d.suggestedFilename(), bytes: fs.readFileSync(out), path: out, said: said.said };
194})();
195check(!!dl, 'saving hands a file over', dl ? dl.name : 'no download');
196check(!!dl && dl.name === small.file, 'under the name share.js said it would',
197 dl ? dl.name + ' vs ' + small.file : '');
198const sealedLen = await page.evaluate(() => window.__small.sealed.length);
199check(!!dl && dl.bytes.length === sealedLen,
200 'and the file IS the sealed envelope, not a re-encoding of it',
201 dl ? dl.bytes.length + ' vs ' + sealedLen : '');
202check(!!dl && /Give them that file/.test(dl.said),
203 'and there is a sentence to show the sender', dl ? dl.said : '');
204
205console.log('\n-- and taking one in, through the browser’s own file chooser --');
206// `pick` is the receiving direction as a person meets it. Driven through
207// Playwright's filechooser event rather than by handing `take` some bytes, so
208// what is proven is the route and not just the function underneath it.
209const picked = await (async () => {
210 const fcp = page.waitForEvent('filechooser', { timeout: 15000 });
211 const landing = page.evaluate(() => window.DaimondShare.pick()
212 .then(r => ({ ok: true, r: r }), e => ({ ok: false, err: String(e && e.message || e) })));
213 const fc = await fcp;
214 await fc.setFiles(dl.path);
215 return await landing;
216})();
217check(picked.ok === true, 'a chosen .dshare lands', picked.ok ? '' : picked.err);
218check(picked.ok && picked.r.ok === true && !!picked.r.id,
219 'as a Diamond of the receiver’s own', picked.ok ? JSON.stringify(picked.r) : '');
220check(picked.ok && picked.r.wrote.join(',') === 'notes.md',
221 'holding the file that was sent', picked.ok ? JSON.stringify(picked.r.wrote) : '');
222const landedText = await page.evaluate(async (id) => {
223 const m = await import('/pkg/oxedyne_daimond.js');
224 return await m.store_read('diamonds/' + id + '/notes.md');
225}, picked.ok ? picked.r.id : '');
226check(/Flour, water, salt/.test(landedText || ''),
227 'and the words that were in it', (landedText || '').slice(0, 60));
228
229console.log('\n-- a capp arriving as a file is still asked about --');
230// THE WHOLE POINT. Data travels freely and code travels only by consent, and
231// nothing about the carrier may change that: a file somebody handed you must
232// never be a way of running their program.
233const asked = await page.evaluate(async () => {
234 const seen = [];
235 const real = DaimondCore.confirm;
236 DaimondCore.confirm = async (body, ok, opts) => {
237 seen.push({ body: body, title: opts && opts.title, danger: !!(opts && opts.danger) });
238 return false; // the receiver says no
239 };
240 try {
241 const r = await DaimondShare.take(window.__capp.sealed);
242 return { seen: seen, r: r };
243 } catch (e) {
244 return { seen: seen, err: String(e && e.message || e) };
245 } finally { DaimondCore.confirm = real; }
246});
247check(asked.seen.length === 1, 'the receiver is asked, once', JSON.stringify(asked.seen.length));
248check(asked.seen.length === 1 && /program written by somebody else/i.test(asked.seen[0].body),
249 'and told WHAT it is: a program somebody else wrote',
250 asked.seen.length ? asked.seen[0].body.slice(0, 90) : 'nothing asked');
251check(asked.seen.length === 1 && /crystal\.html/.test(asked.seen[0].body),
252 'and WHICH file, by name', asked.seen.length ? asked.seen[0].body.slice(-80) : '');
253check(asked.seen.length === 1 && asked.seen[0].danger === true,
254 'asked as a dangerous thing rather than a routine one',
255 asked.seen.length ? String(asked.seen[0].danger) : '');
256// Everything in that share was the page, so saying no leaves nothing to add —
257// and that is reported as a refusal rather than as an empty success.
258check(!asked.err && asked.r && asked.r.ok === false && asked.r.left.join(',') === 'crystal.html',
259 'saying no leaves the page out, by name, and adds nothing',
260 JSON.stringify(asked.r || asked.err));
261
262// A SHARE THAT LANDS SHORT SAYS SO, through the reporter post.js and group.js
263// already use. `accept` answered `ok: true` beside a count of the files it left
264// out and NOTHING anywhere read the count: three of five files landed, success
265// was reported, and the two omitted were never mentioned to the receiver, who
266// cannot go looking for what they were never told about.
267const partial = await page.evaluate(async () => {
268 const to = await DaimondIdentity.publicKeyRaw();
269 const toEnc = DaimondIdentity.sealingKeyRaw();
270 const made = await DaimondShare.compose({
271 name: 'Mixed', to: to, toEnc: toEnc,
272 files: [{ path: 'notes.md', body: '# Data\n' },
273 { path: 'recipe.md', body: '# More data\n' },
274 { path: 'crystal.html', body: '<!doctype html><p>a page</p>' }],
275 });
276 const real = DaimondCore.confirm;
277 const seen = [];
278 DaimondCore.confirm = async (body) => { seen.push(body); return false; };
279 let r;
280 try { r = await DaimondShare.take(made.sealed); }
281 finally { DaimondCore.confirm = real; }
282 // WHAT THE AUTHORITY WOULD SAY ABOUT THE SAME ANSWER, asked separately and
283 // compared below. Naming a wording here would be a third copy of it in the
284 // test, which is the fault under test one level up.
285 return { r, asked: seen.length, authority: window.DaimondPost.shortfall(r).trim() };
286});
287check(partial.r && partial.r.ok === true && partial.r.wrote.length === 2,
288 'a share of three files with one page declined lands the other two',
289 JSON.stringify(partial.r && partial.r.wrote));
290check(partial.r && partial.r.left.join(',') === 'crystal.html',
291 'and still answers WHICH one it left out', JSON.stringify(partial.r && partial.r.left));
292// The half that was missing: a sentence, so the count cannot be returned and
293// ignored. It comes back with the result as well as going on screen, which is
294// what stays true in a build with no dialog on the page.
295check(partial.r && partial.r.said && /crystal\.html/.test(partial.r.said),
296 'AND a sentence naming it — `ok:true` beside an unread count is the defect',
297 partial.r ? JSON.stringify(partial.r.said) : '');
298// AND IT IS THE AUTHORITY'S OWN BYTES, not a wording that resembles them.
299//
300// THIS CHECK NAMED A STRING AND WAS WRONG ABOUT WHICH. It asserted `/Left out/i`
301// -- `post.group_skipped`, the wording of the copy `share.js` carried inside
302// `shortSaid` -- and it went RED on 2026-08-17 when the two refusals were split
303// and `DaimondPost.shortfall` moved to `group.refused`. The verifier was the last
304// thing in the tree still asserting a retired sentence, and it reported the fault
305// as share.js's. So the string is out of the test: what is asked is whether this
306// file's answer is CHARACTER FOR CHARACTER what the shared reporter produces from
307// the same object, which is what "not a third wording" actually means and stays
308// true through the next rewording without anybody editing this line.
309check(partial.r && partial.r.said === partial.authority && !!partial.authority,
310 'and it is the shared reporter\'s own bytes, not a second wording of them',
311 partial.r ? JSON.stringify({ said: partial.r.said, authority: partial.authority }) : '');
312// `skipped` is the shape `DaimondPost.shortfall` reads, so the next field added
313// to this answer is reported by the same function or by nothing.
314check(partial.r && partial.r.skipped && partial.r.skipped.length === 1
315 && partial.r.skipped[0].label === 'crystal.html' && !!partial.r.skipped[0].why,
316 'carried as {label, why}, which is the shared reporter\'s own shape',
317 JSON.stringify(partial.r && partial.r.skipped));
318
319const allowed = await page.evaluate(async () => {
320 const real = DaimondCore.confirm;
321 DaimondCore.confirm = async () => true;
322 try { return await DaimondShare.take(window.__capp.sealed); }
323 catch (e) { return { err: String(e && e.message || e) }; }
324 finally { DaimondCore.confirm = real; }
325});
326check(allowed.ok === true && allowed.wrote.join(',') === 'crystal.html',
327 'and saying yes is what writes it — the gate is on the WRITE, not on the mount',
328 JSON.stringify(allowed));
329
330console.log('\n-- a share carries an image without mangling it --');
331// `export_diamond` reads every file through `from_utf8_lossy`, which turns every
332// non-UTF-8 byte into U+FFFD. That is lane R's to fix and it is not the path a
333// share takes: this checks the SHARE FORMAT carries the bytes intact, and that
334// the landing — which genuinely cannot write them yet — refuses that one file by
335// name rather than writing replacement characters.
336const image = await page.evaluate(async (b64) => {
337 const raw = atob(b64);
338 const png = new Uint8Array(raw.length);
339 for (let i = 0; i < raw.length; i++) png[i] = raw.charCodeAt(i);
340 const to = await DaimondIdentity.publicKeyRaw();
341 const toEnc = DaimondIdentity.sealingKeyRaw();
342 const made = await DaimondShare.compose({
343 name: 'With a picture', to: to, toEnc: toEnc,
344 files: [{ path: 'notes.md', body: '# See the picture\n' },
345 { path: 'shot.png', body: png }],
346 });
347 const read = await DaimondShare.open(made.sealed);
348 try {
349 const out = { paths: [], same: false, sent: Array.from(png) };
350 for (let i = 0; i < read.count(); i++) out.paths.push(read.path(i));
351 const back = read.body(read.path(0) === 'shot.png' ? 0 : 1);
352 out.got = Array.from(back);
353 return out;
354 } finally { try { read.free(); } catch (e) { /* freed */ } }
355}, PNG.toString('base64'));
356check(image.paths.indexOf('shot.png') !== -1, 'the picture is in the share',
357 JSON.stringify(image.paths));
358check(image.got.length === PNG.length
359 && Buffer.compare(Buffer.from(image.got), PNG) === 0,
360 'and comes back out of the sealed envelope byte for byte — no U+FFFD anywhere',
361 image.got.length + ' vs ' + PNG.length);
362
363console.log('\n-- and a file that is not a share is refused before anything is unsealed --');
364const junk = await page.evaluate(async () => {
365 const out = {};
366 try { await DaimondShare.take(new Uint8Array(0)); out.empty = 'landed'; }
367 catch (e) { out.empty = e.message; }
368 try { await DaimondShare.take(new Uint8Array(4096).fill(7)); out.noise = 'landed'; }
369 catch (e) { out.noise = e.message; }
370 try { await DaimondShare.take(new Uint8Array(3 * 1024 * 1024)); out.huge = 'landed'; }
371 catch (e) { out.huge = e.message; }
372 return out;
373});
374check(/not a Daimond share/i.test(junk.empty), 'a file of no bytes is named as not a share',
375 junk.empty);
376check(junk.noise !== 'landed', 'a file of noise does not land', junk.noise);
377check(/larger than any share can be/i.test(junk.huge),
378 'and one larger than any share can be is refused by SIZE, before the seal is touched',
379 junk.huge);
380// The size guard has to come before the seal, or a 3 MB file of noise costs a
381// megabyte of decryption work to say the same sentence.
382check(/3\.0 MB/.test(junk.huge), 'and the sentence says how big it was', junk.huge);
383
384// ── AND SOMEBODY CAN REACH ALL OF IT ────────────────────────────────
385//
386// Everything above proves the carrier works. None of it proved a USER could
387// get at it, and until the Share chip existed none could: share.js was a
388// complete, tested, unreachable module, which is this project's signature
389// failure and had shipped three times before. Forty checks passing against a
390// surface nobody can press prove only that the surface works.
391//
392// So these press the chip the way a person does, and MEASURE it. A control the
393// DOM has and the screen does not is already recorded in this codebase
394// (`daimond.js:8323`), and a lane put a switch in an unfindable place last
395// session and had to move it.
396//
397// TO SEE THESE FAIL:
398//
399// * `www/index.html`: delete the `data-view="share"` chip. Every check in this
400// section goes red and share.js is unreachable again — which is the state
401// they were written against.
402// * `www/js/improve.js`, `VIEWS`: remove the `share:` line. The chip is still
403// on screen and pressing it shows nothing, which is the more interesting
404// failure of the two and the one a DOM-only check would miss.
405// * `share.js`, `sendTo`: report only `r.sent` and drop the `refused` branch.
406// The "names the reason" check goes red and a share nobody could deliver
407// reports silence.
408
409/// Close any dialog standing in front of the panel, and say whether there was
410/// one. A modal intercepts pointer events, so a click that ignores it does not
411/// fail on the control -- it fails on a `<div class="modal dlg">` and reads as a
412/// broken button. `landDiamond` draws a notice when a share lands short, and
413/// share.js draws one too, so a suite that lands anything meets one.
414const dismiss = async () => {
415 let shut = 0;
416 for (let i = 0; i < 4; i++) {
417 const ok = await page.$('.modal.dlg .dlg-ok');
418 if (!ok) break;
419 await ok.click();
420 shut++;
421 await page.waitForTimeout(250);
422 }
423 return shut;
424};
425
426console.log('\n-- the chip is on the head, and pressing it shows the view --');
427const panel = await page.evaluate(() => {
428 try { DaimondPanels.show('social'); } catch (e) { return 'no panels: ' + e.message; }
429 return 'shown';
430});
431check(panel === 'shown', 'the Social panel opens', panel);
432await page.waitForTimeout(400);
433
434const chip = await page.evaluate(() => {
435 const c = document.querySelector('#panel-social .imp-chip[data-view="share"]');
436 if (!c) return null;
437 const r = c.getBoundingClientRect();
438 const st = getComputedStyle(c);
439 return { text: c.textContent.trim(), w: Math.round(r.width), h: Math.round(r.height),
440 vis: st.visibility, display: st.display, op: st.opacity };
441});
442check(!!chip, 'there is a Share chip beside the others',
443 chip ? chip.text : 'no chip in the head');
444// NOT `querySelector` alone, which is what "it is there" usually means and is
445// not the same claim.
446check(!!chip && chip.w > 20 && chip.h > 12,
447 'and it has a box on screen rather than being 0x0',
448 chip ? chip.w + 'x' + chip.h : 'no chip');
449check(!!chip && chip.vis !== 'hidden' && chip.display !== 'none' && Number(chip.op) > 0.5,
450 'and is actually visible, not merely laid out',
451 chip ? JSON.stringify({ vis: chip.vis, display: chip.display, op: chip.op }) : '');
452
453const wasView = await page.evaluate(() => window.DaimondSocial.view());
454await page.click('#panel-social .imp-chip[data-view="share"]');
455await page.waitForTimeout(400);
456const nowView = await page.evaluate(() => ({
457 view: window.DaimondSocial.view(),
458 shown: !document.getElementById('social-share').hidden,
459 pressed: document.querySelector('#panel-social .imp-chip[data-view="share"]')
460 .getAttribute('aria-pressed'),
461 offHidden: document.getElementById('social-share-off').hidden,
462 drew: document.getElementById('social-share-list').children.length,
463}));
464check(wasView !== 'share' && nowView.view === 'share',
465 'a real click switches to it from wherever the panel was', wasView + ' -> ' + nowView.view);
466// The view being SHOWN is the check the DOM-only one misses: the chip can exist
467// and press and still reveal nothing if `VIEWS` has no entry for it.
468check(nowView.shown, 'and the view itself is no longer hidden', JSON.stringify(nowView));
469check(nowView.pressed === 'true', 'and the chip says so to a screen reader',
470 'aria-pressed=' + nowView.pressed);
471check(nowView.offHidden && nowView.drew >= 2,
472 'the honest empty line gives way to the two halves of the feature',
473 JSON.stringify(nowView));
474
475// Nothing here may push the panel sideways. It is 300px and the page must never
476// scroll horizontally.
477const noSpill = await page.evaluate(() => {
478 const l = document.getElementById('social-share-list');
479 return { scrollW: l.scrollWidth, clientW: l.clientWidth,
480 bodyOver: document.documentElement.scrollWidth - document.documentElement.clientWidth };
481});
482check(noSpill.scrollW <= noSpill.clientW + 1 && noSpill.bodyOver <= 0,
483 'and none of it pushes the panel or the page sideways', JSON.stringify(noSpill));
484
485console.log('\n-- with no Diamond open, the send half says why rather than nothing --');
486const bare = await page.evaluate(() => document.getElementById('social-share-list').textContent);
487check(/Open a Diamond to share it/.test(bare),
488 'it names what is missing and what to do about it', bare.slice(-140));
489check(/Open a share file/.test(bare),
490 'while taking one in needs nothing and is offered anyway', bare.slice(0, 60));
491
492console.log('\n-- and with a Diamond and a person, it sends --');
493// A person, recorded the way a person is: this device reads a card. Its own, so
494// the share is sealed to a key this browser can also open -- which is what makes
495// the round trip checkable without a second browser.
496const seeded = await page.evaluate(async () => {
497 // MINTED FIRST. `DaimondTrust.cardText()` reads a card out of storage and
498 // `mintCard` is what puts one there, so parsing before minting parses ''.
499 const minted = await window.DaimondIdentity.mintCard();
500 if (!minted || minted.ok === false) return 'mintCard: ' + JSON.stringify(minted);
501 const card = window.DaimondTrust.parse(window.DaimondTrust.cardText());
502 if (!card) return 'own card did not parse';
503 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.QR);
504 if (window.DaimondPost && DaimondPost.refreshPeople) await DaimondPost.refreshPeople();
505 const folk = (DaimondPost.people() || []).filter(p => p && p.pub && p.enc);
506 return folk.length;
507});
508check(seeded === 1, 'one person is in the directory, with a sealing key', String(seeded));
509
510await dismiss();
511const chose = await page.evaluate(async () => {
512 const tile = document.querySelector('.diamond-list .diamond-box');
513 if (!tile) return 'no Diamond tile in the rail';
514 tile.click();
515 return 'clicked';
516});
517check(chose === 'clicked', 'a Diamond is opened from the rail', chose);
518await page.waitForTimeout(700);
519await dismiss();
520await page.click('#panel-social .imp-chip[data-view="share"]');
521await page.waitForTimeout(400);
522
523const sendRow = await page.evaluate(() => {
524 const b = document.querySelector('#social-share-list .shr-send');
525 const w = document.querySelector('#social-share-list .shr-who');
526 const box = (n) => { if (!n) return null; const r = n.getBoundingClientRect();
527 return { w: Math.round(r.width), h: Math.round(r.height) }; };
528 return { send: box(b), who: box(w),
529 options: w ? Array.from(w.options).map(o => o.textContent) : [],
530 text: document.getElementById('social-share-list').textContent };
531});
532check(!!sendRow.send && sendRow.send.w > 20 && sendRow.send.h > 12,
533 'the Share button appears and has a box',
534 sendRow.send ? sendRow.send.w + 'x' + sendRow.send.h : 'no button');
535check(!!sendRow.who && sendRow.who.w > 20 && sendRow.who.h > 12,
536 'so does the list of who it goes to',
537 sendRow.who ? sendRow.who.w + 'x' + sendRow.who.h : 'no picker');
538check(sendRow.options.length === 1, 'holding the one person there is',
539 JSON.stringify(sendRow.options));
540check(/a copy they will own/.test(sendRow.text),
541 'and it says what a share IS before anybody presses anything',
542 sendRow.text.slice(0, 200));
543
544// THE RELAY IS NOT RUNNING IN THIS WORLD, which is the interesting case rather
545// than a limitation: `fanout` cannot deliver, and a caller that read only `sent`
546// would report nothing at all. The panel must name the refusal AND fall back to
547// the file, because a share nobody could deliver and nobody was told about is
548// the same defect as a landing that counts what it left out and says none of it.
549const sent = await (async () => {
550 const wait = page.waitForEvent('download', { timeout: 20000 }).catch(() => null);
551 await dismiss();
552 await page.click('#social-share-list .shr-send');
553 await page.waitForTimeout(2500);
554 const d = await wait;
555 let file = null;
556 if (d) {
557 const out = scratch('share-out', 'panel-' + d.suggestedFilename());
558 await d.saveAs(out);
559 file = { name: d.suggestedFilename(), bytes: fs.readFileSync(out) };
560 }
561 return { file: file, said: await page.evaluate(() =>
562 Array.from(document.querySelectorAll('#social-share-list .shr-say'))
563 .filter(n => !n.hidden).map(n => n.textContent).join(' | ')) };
564})();
565// THREE OUTCOMES, ALL OF THEM SAID. Sent through the relay; refused by the relay
566// and saved instead; or too large for the relay and saved without ever asking it.
567// The third is the one the whole carrier exists for and it is what happens here:
568// the Diamond the rail offers is about 100 KB, well over the 64 KiB ceiling, so
569// this is the capp-sized case arriving by the front door.
570check(/would not take it|Sent to|Saved as/.test(sent.said),
571 'pressing Share says which of the three things happened', sent.said.slice(0, 200));
572check(/travels as a file|would not take it/.test(sent.said),
573 'and where a share is too large for the relay, says so with both sizes in it',
574 sent.said.slice(0, 260));
575check(!!sent.file, 'and a relay that cannot be reached still leaves the user a file',
576 sent.file ? sent.file.name : 'no download');
577check(!!sent.file && /\.dshare$/.test(sent.file.name) && sent.file.bytes.length > 200,
578 'a real sealed .dshare, not an empty one',
579 sent.file ? sent.file.name + ' ' + sent.file.bytes.length + 'B' : '');
580check(/give them that|Give them that file/i.test(sent.said),
581 'and tells them what to do with it — a refusal with no next step is no use',
582 sent.said.slice(0, 240));
583
584console.log('\n-- and the chip takes one in, through the chooser --');
585const tookIt = await (async () => {
586 await dismiss();
587 const fcp = page.waitForEvent('filechooser', { timeout: 15000 });
588 await page.click('#social-share-list .shr-take');
589 const fc = await fcp;
590 await fc.setFiles(dl.path);
591 await page.waitForTimeout(2500);
592 return await page.evaluate(() =>
593 Array.from(document.querySelectorAll('#social-share-list .shr-say'))
594 .filter(n => !n.hidden).map(n => n.textContent).join(' | '));
595})();
596check(/file\(s\) arrived|Added as a Diamond/.test(tookIt),
597 'the button opens the chooser and lands what is chosen', tookIt.slice(0, 200));
598
599console.log('\n-- a picture LANDS now, and survives a sync round --');
600// `store_write_bytes` is new: `store_read_bytes` had existed all along, so the
601// store could be read byte for byte and not written that way, and a share
602// carrying a PNG had nowhere to put it. The wire was sound and the landing was
603// not -- which the byte-for-byte envelope check above proved from the other side.
604//
605// THE STAMP IS THE DANGEROUS HALF AND IT IS WHY THIS SECTION EXISTS. A raw OPFS
606// write moves nothing, so a Diamond written into and not stamped is strictly
607// STALER than every other device's copy: `applyDiamonds` replaces it wholesale
608// from the fresher side and the picture goes with the copy it replaced. That is
609// the tag-loss data-loss failure of 11 August arriving through a new door, and
610// the files coming this way are the large ones a person would actually notice
611// losing. "It landed" is not the claim worth checking; "it is still there after
612// a sync" is.
613//
614// AND ONE THING HERE DOES NOT DISCRIMINATE, WHICH IS WORTH SAYING RATHER THAN
615// LEAVING FOR SOMEBODY TO FIND. Disabling `Wasm.touch_diamond(id)` in
616// `landDiamond` leaves every check below GREEN -- measured, not reasoned. On this
617// path `create_diamond` runs first and stamps `touched` itself, so a landed
618// Diamond is fresh whether or not the landing stamps it again. The stamp check
619// therefore proves the PROPERTY (this copy will win arbitration) and not the
620// MECHANISM (that the call is what achieves it).
621//
622// The call stays, because the path it defends is the other one: a write into a
623// Diamond that ALREADY EXISTS -- a capp logging a meal, which is the 11 August
624// failure verbatim -- has no `create_diamond` in front of it and nothing else to
625// stamp it. A check that discriminated would have to write through
626// `store_write_bytes` into an existing Diamond, and no production path in this
627// app does that yet; when one arrives, it is the caller that needs this check and
628// not this one.
629const doorThere = await page.evaluate(async () => {
630 const m = await import('/pkg/oxedyne_daimond.js');
631 return typeof m.store_write_bytes === 'function';
632});
633check(doorThere, 'the bundle carries store_write_bytes', String(doorThere));
634
635const before = Date.now();
636const landedPic = await page.evaluate(async (b64) => {
637 const raw = atob(b64);
638 const png = new Uint8Array(raw.length);
639 for (let i = 0; i < raw.length; i++) png[i] = raw.charCodeAt(i);
640 const to = await DaimondIdentity.publicKeyRaw();
641 const toEnc = DaimondIdentity.sealingKeyRaw();
642 const made = await DaimondShare.compose({
643 name: 'Holiday photos', to: to, toEnc: toEnc,
644 files: [{ path: 'notes.md', body: '# The harbour\n' },
645 { path: 'shot.png', body: png }],
646 });
647 const r = await DaimondShare.take(made.sealed);
648 const m = await import('/pkg/oxedyne_daimond.js');
649 let back = null;
650 try {
651 back = Array.from(await m.store_read_bytes('diamonds/' + r.id + '/shot.png', 0, 4096));
652 } catch (e) { back = 'unreadable: ' + e.message; }
653 // The stamp, read the way arbitration reads it.
654 let stamp = 0, why = '';
655 try {
656 const list = JSON.parse(await DaimondCore.diamondApp().list_diamonds());
657 list.forEach(function (d) { if (d && d.id === r.id) stamp = Number(d.touched) || 0; });
658 } catch (e) { why = 'could not read the stamp: ' + e.message; }
659 return { r: r, back: back, stamp: stamp, why: why, sent: Array.from(png) };
660}, PNG.toString('base64'));
661
662check(landedPic.r && landedPic.r.ok === true
663 && landedPic.r.wrote.indexOf('shot.png') !== -1,
664 'a share carrying a picture lands the picture — it used to be refused by name',
665 JSON.stringify(landedPic.r));
666check(Array.isArray(landedPic.back)
667 && Buffer.compare(Buffer.from(landedPic.back), PNG) === 0,
668 'and the bytes on disk are the bytes that were sent, not replacement characters',
669 Array.isArray(landedPic.back)
670 ? landedPic.back.length + ' vs ' + PNG.length : String(landedPic.back));
671// A stamp of 0, or one older than the moment before the landing, is the failure.
672check(landedPic.stamp >= before,
673 'the landed copy is fresh enough to win arbitration (create_diamond stamps it; '
674 + 'see the note above on what this does NOT prove)',
675 landedPic.why || ('touched=' + landedPic.stamp + ' vs landed at ' + before));
676
677// The sync round itself: a parcel from "the other device" carrying the same
678// Diamond with an OLDER stamp. The local copy must win, and the picture must
679// still be there afterwards.
680const survived = await page.evaluate(async (id) => {
681 const m = await import('/pkg/oxedyne_daimond.js');
682 const app = DaimondCore.diamondApp();
683 let mine = null;
684 JSON.parse(await app.list_diamonds()).forEach(function (d) {
685 if (d && d.id === id) mine = d;
686 });
687 if (!mine) return { why: 'the landed Diamond is not in the list' };
688 // The other device's copy of the SAME Diamond, a minute staler and with no
689 // picture in it. Fed through the door a real pull feeds.
690 const stale = JSON.parse(JSON.stringify(mine));
691 stale.touched = (Number(mine.touched) || 0) - 60000;
692 const parcel = { v: 2, diamonds: [{ id: id, data: stale.data || stale,
693 touched: stale.touched }] };
694 let applied = 'ok';
695 try { await DaimondSync.apply(parcel); }
696 catch (e) { applied = 'threw: ' + e.message; }
697 let back = null;
698 try {
699 back = Array.from(await m.store_read_bytes('diamonds/' + id + '/shot.png', 0, 4096));
700 } catch (e) { back = 'gone: ' + e.message; }
701 let still = false;
702 JSON.parse(await app.list_diamonds()).forEach(function (d) { if (d && d.id === id) still = true; });
703 return { applied: applied, back: back, still: still };
704}, landedPic.r && landedPic.r.id);
705
706check(!survived.why, 'the landed Diamond is listed', survived.why || '');
707check(survived.still === true, 'a staler copy from another device does not delete it',
708 JSON.stringify({ applied: survived.applied, still: survived.still }));
709check(Array.isArray(survived.back)
710 && Buffer.compare(Buffer.from(survived.back), PNG) === 0,
711 'and the picture is STILL there, byte for byte, after the merge',
712 Array.isArray(survived.back)
713 ? survived.back.length + ' bytes' : String(survived.back));
714
715console.log('\n' + pass + ' passed, ' + fail + ' failed');
716await s.close();
717process.exit(fail ? 1 : 0);