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 | |
| 42 | import fs from 'node:fs'; |
| 43 | import { open, scratch } from './harness.mjs'; |
| 44 | |
| 45 | const PROFILE = scratch('pw', 'share'); |
| 46 | |
| 47 | let pass = 0, fail = 0; |
| 48 | const 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. |
| 56 | const PNG = Buffer.from( |
| 57 | 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', |
| 58 | 'base64'); |
| 59 | |
| 60 | const s = await open({ name: 'share', profile: PROFILE, connect: false }); |
| 61 | const { page } = s; |
| 62 | |
| 63 | console.log('\n-- this build has a carrier at all --'); |
| 64 | const 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 | }); |
| 74 | check(!!surface, 'share.js is on the page', surface ? '' : 'no window.DaimondShare'); |
| 75 | check(!!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. |
| 80 | check(!!surface && surface.ext === '.dshare', 'the file is a .dshare', |
| 81 | surface ? surface.ext : ''); |
| 82 | check(!!surface && surface.mime === 'application/octet-stream', |
| 83 | 'carried as application/octet-stream', surface ? surface.mime : ''); |
| 84 | check(!!surface && surface.fns.length === 6, |
| 85 | 'and the carrier is reachable: carrier, carrierWhy, fitsRelay, save, take, pick', |
| 86 | surface ? surface.fns.join(', ') : 'none'); |
| 87 | |
| 88 | console.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. |
| 92 | const gw = fs.readFileSync(new URL('../gateway/src/settings.rs', import.meta.url), 'utf8'); |
| 93 | const postBlock = gw.slice(gw.indexOf('route: "/api/post"'), gw.indexOf('route: "/api/post"') + 900); |
| 94 | const fallback = (postBlock.match(/fallback:\s*"(\d+)"/) || [])[1]; |
| 95 | check(fallback === '65536', 'the /api/post max_bytes knob still falls back to 64 KiB', |
| 96 | 'gateway says ' + fallback); |
| 97 | check(!!surface && surface.limits.relay === 65536, |
| 98 | 'and share.js holds the same number', surface ? String(surface.limits.relay) : ''); |
| 99 | |
| 100 | const 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. |
| 112 | check(fits.at64k === false, 'a sealed envelope of exactly 64 KiB does NOT fit: base64 rounds up', |
| 113 | JSON.stringify(fits)); |
| 114 | check(fits.one_under === true, 'and 65,535 bytes is the last size that does', |
| 115 | JSON.stringify(fits)); |
| 116 | check(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. |
| 120 | const gateway = (n) => { |
| 121 | const chars = Math.ceil(n / 3) * 4; // base64, padded |
| 122 | return !(Math.floor(chars / 4) * 3 > 65536) && !(n > 65536); |
| 123 | }; |
| 124 | let boundaryAgree = true; |
| 125 | for (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 | } |
| 129 | check(boundaryAgree, 'and the client agrees with the handler’s arithmetic at every boundary'); |
| 130 | |
| 131 | console.log('\n-- a small share goes by relay; a capp cannot --'); |
| 132 | const 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 | }); |
| 144 | check(small.carrier === 'relay', 'a recipe goes through the relay', |
| 145 | small.carrier + ' at ' + small.sealed + ' bytes'); |
| 146 | check(/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`. |
| 151 | check(small.name === 'Recipe book' && /^Recipe-book-/.test(small.file), |
| 152 | 'the file carries the name the sender chose', small.file); |
| 153 | check(/\.dshare$/.test(small.file), 'and the extension', small.file); |
| 154 | check(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 | |
| 157 | const 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 | }); |
| 172 | check(capp.sealed > 65536, 'a capp share really is over the relay’s ceiling', |
| 173 | capp.sealed + ' bytes sealed'); |
| 174 | check(capp.carrier === 'file', |
| 175 | 'so it takes the file route — this is the case that had NO carrier at all', capp.carrier); |
| 176 | check(capp.code === true, 'and the payload’s own signed claim says it carries code', |
| 177 | String(capp.code)); |
| 178 | check(/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 | |
| 182 | console.log('\n-- writing one out --'); |
| 183 | const 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 | })(); |
| 195 | check(!!dl, 'saving hands a file over', dl ? dl.name : 'no download'); |
| 196 | check(!!dl && dl.name === small.file, 'under the name share.js said it would', |
| 197 | dl ? dl.name + ' vs ' + small.file : ''); |
| 198 | const sealedLen = await page.evaluate(() => window.__small.sealed.length); |
| 199 | check(!!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 : ''); |
| 202 | check(!!dl && /Give them that file/.test(dl.said), |
| 203 | 'and there is a sentence to show the sender', dl ? dl.said : ''); |
| 204 | |
| 205 | console.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. |
| 209 | const 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 | })(); |
| 217 | check(picked.ok === true, 'a chosen .dshare lands', picked.ok ? '' : picked.err); |
| 218 | check(picked.ok && picked.r.ok === true && !!picked.r.id, |
| 219 | 'as a Diamond of the receiver’s own', picked.ok ? JSON.stringify(picked.r) : ''); |
| 220 | check(picked.ok && picked.r.wrote.join(',') === 'notes.md', |
| 221 | 'holding the file that was sent', picked.ok ? JSON.stringify(picked.r.wrote) : ''); |
| 222 | const 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 : ''); |
| 226 | check(/Flour, water, salt/.test(landedText || ''), |
| 227 | 'and the words that were in it', (landedText || '').slice(0, 60)); |
| 228 | |
| 229 | console.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. |
| 233 | const 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 | }); |
| 247 | check(asked.seen.length === 1, 'the receiver is asked, once', JSON.stringify(asked.seen.length)); |
| 248 | check(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'); |
| 251 | check(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) : ''); |
| 253 | check(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. |
| 258 | check(!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. |
| 267 | const 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 | }); |
| 287 | check(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)); |
| 290 | check(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. |
| 295 | check(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. |
| 309 | check(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. |
| 314 | check(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 | |
| 319 | const 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 | }); |
| 326 | check(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 | |
| 330 | console.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. |
| 336 | const 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')); |
| 356 | check(image.paths.indexOf('shot.png') !== -1, 'the picture is in the share', |
| 357 | JSON.stringify(image.paths)); |
| 358 | check(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 | |
| 363 | console.log('\n-- and a file that is not a share is refused before anything is unsealed --'); |
| 364 | const 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 | }); |
| 374 | check(/not a Daimond share/i.test(junk.empty), 'a file of no bytes is named as not a share', |
| 375 | junk.empty); |
| 376 | check(junk.noise !== 'landed', 'a file of noise does not land', junk.noise); |
| 377 | check(/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. |
| 382 | check(/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. |
| 414 | const 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 | |
| 426 | console.log('\n-- the chip is on the head, and pressing it shows the view --'); |
| 427 | const panel = await page.evaluate(() => { |
| 428 | try { DaimondPanels.show('social'); } catch (e) { return 'no panels: ' + e.message; } |
| 429 | return 'shown'; |
| 430 | }); |
| 431 | check(panel === 'shown', 'the Social panel opens', panel); |
| 432 | await page.waitForTimeout(400); |
| 433 | |
| 434 | const 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 | }); |
| 442 | check(!!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. |
| 446 | check(!!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'); |
| 449 | check(!!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 | |
| 453 | const wasView = await page.evaluate(() => window.DaimondSocial.view()); |
| 454 | await page.click('#panel-social .imp-chip[data-view="share"]'); |
| 455 | await page.waitForTimeout(400); |
| 456 | const 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 | })); |
| 464 | check(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. |
| 468 | check(nowView.shown, 'and the view itself is no longer hidden', JSON.stringify(nowView)); |
| 469 | check(nowView.pressed === 'true', 'and the chip says so to a screen reader', |
| 470 | 'aria-pressed=' + nowView.pressed); |
| 471 | check(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. |
| 477 | const 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 | }); |
| 482 | check(noSpill.scrollW <= noSpill.clientW + 1 && noSpill.bodyOver <= 0, |
| 483 | 'and none of it pushes the panel or the page sideways', JSON.stringify(noSpill)); |
| 484 | |
| 485 | console.log('\n-- with no Diamond open, the send half says why rather than nothing --'); |
| 486 | const bare = await page.evaluate(() => document.getElementById('social-share-list').textContent); |
| 487 | check(/Open a Diamond to share it/.test(bare), |
| 488 | 'it names what is missing and what to do about it', bare.slice(-140)); |
| 489 | check(/Open a share file/.test(bare), |
| 490 | 'while taking one in needs nothing and is offered anyway', bare.slice(0, 60)); |
| 491 | |
| 492 | console.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. |
| 496 | const 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 | }); |
| 508 | check(seeded === 1, 'one person is in the directory, with a sealing key', String(seeded)); |
| 509 | |
| 510 | await dismiss(); |
| 511 | const 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 | }); |
| 517 | check(chose === 'clicked', 'a Diamond is opened from the rail', chose); |
| 518 | await page.waitForTimeout(700); |
| 519 | await dismiss(); |
| 520 | await page.click('#panel-social .imp-chip[data-view="share"]'); |
| 521 | await page.waitForTimeout(400); |
| 522 | |
| 523 | const 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 | }); |
| 532 | check(!!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'); |
| 535 | check(!!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'); |
| 538 | check(sendRow.options.length === 1, 'holding the one person there is', |
| 539 | JSON.stringify(sendRow.options)); |
| 540 | check(/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. |
| 549 | const 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. |
| 570 | check(/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)); |
| 572 | check(/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)); |
| 575 | check(!!sent.file, 'and a relay that cannot be reached still leaves the user a file', |
| 576 | sent.file ? sent.file.name : 'no download'); |
| 577 | check(!!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' : ''); |
| 580 | check(/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 | |
| 584 | console.log('\n-- and the chip takes one in, through the chooser --'); |
| 585 | const 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 | })(); |
| 596 | check(/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 | |
| 599 | console.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. |
| 629 | const doorThere = await page.evaluate(async () => { |
| 630 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 631 | return typeof m.store_write_bytes === 'function'; |
| 632 | }); |
| 633 | check(doorThere, 'the bundle carries store_write_bytes', String(doorThere)); |
| 634 | |
| 635 | const before = Date.now(); |
| 636 | const 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 | |
| 662 | check(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)); |
| 666 | check(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. |
| 672 | check(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. |
| 680 | const 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 | |
| 706 | check(!survived.why, 'the landed Diamond is listed', survived.why || ''); |
| 707 | check(survived.still === true, 'a staler copy from another device does not delete it', |
| 708 | JSON.stringify({ applied: survived.applied, still: survived.still })); |
| 709 | check(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 | |
| 715 | console.log('\n' + pass + ' passed, ' + fail + ' failed'); |
| 716 | await s.close(); |
| 717 | process.exit(fail ? 1 : 0); |