oxedyne/daimond/dev/verify_fsnames.mjs
12.4 KiB, 1 run
created by r2519314175:429, 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_fsnames.mjs — a name the workspace uses, and the name the filesystem will take. |
| 2 | // |
| 3 | // A Maildir message is called `<uid>.<uidvalidity>.daimond:2,<flags>`. The `:2,` is the Maildir |
| 4 | // standard's and the flags after it are what the mail panel matches on, so the colon is not ours to |
| 5 | // drop. It is also refused by every File System Access root except a modern browser's own sandbox — |
| 6 | // including the real local folder a user may have open, which is exactly where `mail/…` lands — and |
| 7 | // the refusal reached a user as: |
| 8 | // |
| 9 | // OPFS: open/create file '70074.3.daimond:2,' failed: JsValue(TypeError: Failed to execute |
| 10 | // 'getFileHandle' on 'FileSystemDirectoryHandle': Name is not allowed.) |
| 11 | // |
| 12 | // THE STRICT ROOT IS STOOD IN FOR, and it has to be. Chromium 149 and 150 and Firefox 151 all |
| 13 | // ACCEPT that name in their sandbox: `FileSystemAccessManagerImpl::IsSafePathComponent` returns |
| 14 | // early for `storage::kFileSystemTypeTemporary`, testing only `.`, `..`, `/` and `\`. Every other |
| 15 | // root falls through to `base::i18n::IsFilenameLegal`, whose illegal set is the ICU pattern |
| 16 | // `[["*/:<>?\\|][:Cc:][:Cf:]]`. A picker cannot be driven headlessly, so the strict rule is put on |
| 17 | // the prototype instead — the same device by which verify_fsa stands an OPFS subdirectory in for a |
| 18 | // real folder, and the same one the user's own browser applies. |
| 19 | // |
| 20 | // The stand-in enforces the CHARACTER half of the strict rule and not its position-dependent half |
| 21 | // (a leading or trailing space, `.` or `~`). That is deliberate and matches src/fsname.rs: those |
| 22 | // are legal in every sandbox, `foo.txt~` is a name people have, and escaping them would move a file |
| 23 | // that is already on disk. |
| 24 | // |
| 25 | // Run with dev/serve.mjs up (DAIMOND_PORT, default 8777). No gateway, no mock model. |
| 26 | import { open, signInAs, shot } from './harness.mjs'; |
| 27 | |
| 28 | const ok = [], bad = []; |
| 29 | const check = (name, pass, detail) => { |
| 30 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 31 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 32 | }; |
| 33 | |
| 34 | const MAILDIR = 'mail/alice@test.local/INBOX/cur'; |
| 35 | const MSG = '70074.3.daimond:2,S'; |
| 36 | const BODY = 'From: a@b.test\r\nSubject: the colon stays\r\n\r\nbody\r\n'; |
| 37 | |
| 38 | const s = await open({ name: 'fsnames', connect: false }); |
| 39 | const p = s.page; |
| 40 | await p.waitForTimeout(1500); |
| 41 | |
| 42 | // ── The strict root, stood in for ─────────────────────────────────────── |
| 43 | // |
| 44 | // Installed on the prototype, so the handles the wasm already holds are governed by it too. |
| 45 | |
| 46 | const armed = await p.evaluate(() => { |
| 47 | const P = FileSystemDirectoryHandle.prototype; |
| 48 | if (P.__strictArmed) return 'already'; |
| 49 | const ILLEGAL = /["*\/:<>?\\|\x00-\x1F\x7F]/; |
| 50 | const bad = (name) => { |
| 51 | const n = String(name); |
| 52 | return n === '' || n === '.' || n === '..' || ILLEGAL.test(n); |
| 53 | }; |
| 54 | const guard = (fn, api) => function (name, ...rest) { |
| 55 | if (bad(name)) { |
| 56 | return Promise.reject(new TypeError( |
| 57 | "Failed to execute '" + api + "' on 'FileSystemDirectoryHandle': Name is not allowed.")); |
| 58 | } |
| 59 | return fn.call(this, name, ...rest); |
| 60 | }; |
| 61 | P.getFileHandle = guard(P.getFileHandle, 'getFileHandle'); |
| 62 | P.getDirectoryHandle = guard(P.getDirectoryHandle, 'getDirectoryHandle'); |
| 63 | P.removeEntry = guard(P.removeEntry, 'removeEntry'); |
| 64 | P.__strictArmed = true; |
| 65 | return 'armed'; |
| 66 | }); |
| 67 | |
| 68 | // PROVE THE INSTRUMENT. A stand-in that does not bite turns every assertion below into a |
| 69 | // statement about a browser nobody has. |
| 70 | const instrument = await p.evaluate(async () => { |
| 71 | const root = await navigator.storage.getDirectory(); |
| 72 | let refused = '', accepted = ''; |
| 73 | try { await root.getFileHandle('a:b', { create: true }); refused = '(accepted it!)'; } |
| 74 | catch (e) { refused = String(e && e.message); } |
| 75 | try { await root.getFileHandle('instrument.txt', { create: true }); accepted = 'ok'; } |
| 76 | catch (e) { accepted = String(e && e.message); } |
| 77 | return { refused, accepted }; |
| 78 | }); |
| 79 | check('the strict-root stand-in is armed', armed === 'armed', armed); |
| 80 | check('and it refuses a colon in the browser’s own words', |
| 81 | /Name is not allowed/.test(instrument.refused), instrument.refused); |
| 82 | check('while an ordinary name still opens', instrument.accepted === 'ok', instrument.accepted); |
| 83 | |
| 84 | // ── The defect: a Maildir message, through the door mail uses ─────────── |
| 85 | // |
| 86 | // `DaimondApp.write_bytes` is the one door mail's `deps.writeBytes` goes through, so this is the |
| 87 | // user's path and not a path built for the test. |
| 88 | |
| 89 | const wrote = await p.evaluate(async ({ dir, msg, body }) => { |
| 90 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 91 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 92 | window.__fs = { mod, app }; |
| 93 | const bytes = new TextEncoder().encode(body); |
| 94 | try { await app.write_bytes(dir + '/' + msg, bytes); return 'ok'; } |
| 95 | catch (e) { return String((e && (e.message || e)) || e); } |
| 96 | }, { dir: MAILDIR, msg: MSG, body: BODY }); |
| 97 | check('a Maildir message can be written at all', wrote === 'ok', wrote.slice(0, 200)); |
| 98 | |
| 99 | const readBack = await p.evaluate(async ({ dir, msg }) => { |
| 100 | try { |
| 101 | const b = await __fs.mod.read_bytes(dir + '/' + msg, 0, 4096); |
| 102 | return new TextDecoder().decode(b); |
| 103 | } catch (e) { return 'ERROR ' + String((e && (e.message || e)) || e); } |
| 104 | }, { dir: MAILDIR, msg: MSG }); |
| 105 | check('and read back byte for byte', readBack === BODY, readBack.slice(0, 120).replace(/\r?\n/g, '⏎')); |
| 106 | |
| 107 | // The listing is what mail matches flags on, so it has to give back the name the workspace used — |
| 108 | // not the name the browser stored. |
| 109 | const listed = await p.evaluate(async ({ dir }) => { |
| 110 | try { |
| 111 | const j = await __fs.app.run_tool('file_list', JSON.stringify({ path: dir })); |
| 112 | return String(j); |
| 113 | } catch (e) { return 'ERROR ' + String((e && (e.message || e)) || e); } |
| 114 | }, { dir: MAILDIR }); |
| 115 | check('and listed under the name the workspace gave it, flags and all', |
| 116 | listed.indexOf(MSG) > -1, listed.replace(/\s+/g, ' ').slice(0, 160)); |
| 117 | |
| 118 | // What actually landed on disk: a legal name, and exactly one file. |
| 119 | const onDisk = await p.evaluate(async ({ dir }) => { |
| 120 | let d = await DaimondCloud.opfsRoot(); |
| 121 | for (const seg of dir.split('/')) d = await d.getDirectoryHandle(seg); |
| 122 | const names = []; |
| 123 | for await (const ent of d.entries()) names.push(ent[0]); |
| 124 | return names; |
| 125 | }, { dir: MAILDIR }); |
| 126 | check('one file on disk, under a name the strict root accepts', |
| 127 | onDisk.length === 1 && !/[":*<>?\\|]/.test(onDisk[0]), JSON.stringify(onDisk)); |
| 128 | |
| 129 | // ── A store written before the codec existed ──────────────────────────── |
| 130 | // |
| 131 | // The stand-in comes off: a sandbox that accepts a colon is where the legacy names actually are, |
| 132 | // and every user whose mail HAS been syncing has them. Nothing may move, and nothing may fork. |
| 133 | |
| 134 | // A reload is a new realm, so the prototype it was put on goes with the old one. |
| 135 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 136 | await signInAs(s, 'fsnames'); |
| 137 | await p.waitForTimeout(1500); |
| 138 | |
| 139 | const LEGACY_DIR = 'mail/bob@test.local/INBOX/cur'; |
| 140 | const LEGACY = '90001.7.daimond:2,'; |
| 141 | const LEGACY_BODY = 'From: legacy@b.test\r\n\r\nwritten before the codec\r\n'; |
| 142 | |
| 143 | const seeded = await p.evaluate(async ({ dir, name, body }) => { |
| 144 | let d = await DaimondCloud.opfsRoot(); |
| 145 | for (const seg of dir.split('/')) d = await d.getDirectoryHandle(seg, { create: true }); |
| 146 | try { |
| 147 | const fh = await d.getFileHandle(name, { create: true }); |
| 148 | const w = await fh.createWritable(); |
| 149 | await w.write(new TextEncoder().encode(body)); |
| 150 | await w.close(); |
| 151 | return 'ok'; |
| 152 | } catch (e) { return String(e && e.message); } |
| 153 | }, { dir: LEGACY_DIR, name: LEGACY, body: LEGACY_BODY }); |
| 154 | check('this sandbox can hold a legacy name at all, so the case is real', |
| 155 | seeded === 'ok', seeded); |
| 156 | |
| 157 | const legacyRead = await p.evaluate(async ({ dir, name }) => { |
| 158 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 159 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 160 | window.__fs = { mod, app }; |
| 161 | try { |
| 162 | const b = await mod.read_bytes(dir + '/' + name, 0, 4096); |
| 163 | return new TextDecoder().decode(b); |
| 164 | } catch (e) { return 'ERROR ' + String((e && (e.message || e)) || e); } |
| 165 | }, { dir: LEGACY_DIR, name: LEGACY }); |
| 166 | check('a message stored before the codec is still readable, unmoved', |
| 167 | legacyRead === LEGACY_BODY, legacyRead.slice(0, 90).replace(/\r?\n/g, '⏎')); |
| 168 | |
| 169 | const legacyRewrite = await p.evaluate(async ({ dir, name }) => { |
| 170 | const again = 'From: legacy@b.test\r\n\r\nrewritten in place\r\n'; |
| 171 | await __fs.app.write_bytes(dir + '/' + name, new TextEncoder().encode(again)); |
| 172 | let d = await DaimondCloud.opfsRoot(); |
| 173 | for (const seg of dir.split('/')) d = await d.getDirectoryHandle(seg); |
| 174 | const names = []; |
| 175 | for await (const ent of d.entries()) names.push(ent[0]); |
| 176 | const b = await __fs.mod.read_bytes(dir + '/' + name, 0, 4096); |
| 177 | return { names, text: new TextDecoder().decode(b) }; |
| 178 | }, { dir: LEGACY_DIR, name: LEGACY }); |
| 179 | check('and rewriting it keeps ONE file, at the name it already had', |
| 180 | legacyRewrite.names.length === 1 && legacyRewrite.names[0] === LEGACY, |
| 181 | JSON.stringify(legacyRewrite.names)); |
| 182 | check('with the new bytes in it', |
| 183 | /rewritten in place/.test(legacyRewrite.text), legacyRewrite.text.slice(0, 60).replace(/\r?\n/g, '⏎')); |
| 184 | |
| 185 | // An ordinary name is byte-identical on disk, which is the whole of the no-migration claim. |
| 186 | const ordinary = await p.evaluate(async () => { |
| 187 | const names = ['crystal.json', 'notes.txt', 'file%20name', 'foo.txt~', '100%', 'café.md']; |
| 188 | for (const n of names) { |
| 189 | await __fs.app.write_bytes('plainbox/' + n, new TextEncoder().encode('x')); |
| 190 | } |
| 191 | const d = await (await DaimondCloud.opfsRoot()).getDirectoryHandle('plainbox'); |
| 192 | const got = []; |
| 193 | for await (const ent of d.entries()) got.push(ent[0]); |
| 194 | return { want: names.slice().sort(), got: got.sort() }; |
| 195 | }); |
| 196 | check('an ordinary name is the same bytes on disk it always was', |
| 197 | JSON.stringify(ordinary.want) === JSON.stringify(ordinary.got), |
| 198 | JSON.stringify(ordinary.got)); |
| 199 | |
| 200 | // ── The two implementations of the codec agree ────────────────────────── |
| 201 | // |
| 202 | // There are two because the workspace walkers in daimond.js reach the browser's handles directly |
| 203 | // rather than through the wasm. Two that are never compared are two that will drift. |
| 204 | |
| 205 | const codec = await p.evaluate(() => { |
| 206 | const corpus = [ |
| 207 | '70074.3.daimond:2,', '70074.3.daimond:2,FRS', 'crystal.json', '.daimond', |
| 208 | 'file%20name', '100%', '%', '%%', '%25', '%3A', '%3a', 'a%3Ab', 'https%3A%2F%2Fe.com', |
| 209 | '"', '*', '/', ':', '<', '>', '?', '\\', '|', 'a"b*c:d<e>f?g\\h|i', |
| 210 | '', '.', '..', '...', 'é', '日本語.txt', 'Ω:Ω', 'foo.txt~', ' lead', 'trail ', |
| 211 | 'x'.repeat(255), 'x'.repeat(256), |
| 212 | ]; |
| 213 | for (let c = 0; c < 0x80; c++) { |
| 214 | corpus.push(String.fromCharCode(c)); |
| 215 | corpus.push('a' + String.fromCharCode(c) + 'b'); |
| 216 | } |
| 217 | const R = window.__fs.mod; |
| 218 | const J = window.DaimondCloud; |
| 219 | // Reported rather than thrown: a build without one of the two codecs is exactly the state |
| 220 | // this check exists to name, and a thrown TypeError would take the whole run down with it. |
| 221 | if (typeof R.fs_disk_name !== 'function') { |
| 222 | return { n: 0, disagree: [['(the wasm exports no fs_disk_name)']], notRound: [], merged: [] }; |
| 223 | } |
| 224 | if (!J || typeof J.diskName !== 'function') { |
| 225 | return { n: 0, disagree: [['(cloud.js publishes no diskName)']], notRound: [], merged: [] }; |
| 226 | } |
| 227 | const disagree = [], notRound = [], collide = new Map(); |
| 228 | for (const s of corpus) { |
| 229 | const r = R.fs_disk_name(s), j = J.diskName(s); |
| 230 | if (r !== j) disagree.push([s, r, j]); |
| 231 | if (R.fs_logical_name(r) !== s || J.logicalName(j) !== s) notRound.push([s, r]); |
| 232 | if (collide.has(r) && collide.get(r) !== s) collide.set(r, [collide.get(r), s]); |
| 233 | else if (!collide.has(r)) collide.set(r, s); |
| 234 | } |
| 235 | const merged = [...collide.entries()].filter(([, v]) => Array.isArray(v)); |
| 236 | return { |
| 237 | n: corpus.length, |
| 238 | disagree: disagree.slice(0, 3), |
| 239 | notRound: notRound.slice(0, 3), |
| 240 | merged: merged.slice(0, 3), |
| 241 | }; |
| 242 | }); |
| 243 | check('the Rust codec and the JavaScript one agree, name for name', |
| 244 | codec.disagree.length === 0, codec.n + ' names · ' + JSON.stringify(codec.disagree)); |
| 245 | check('and both are reversible over the same corpus', |
| 246 | codec.notRound.length === 0, JSON.stringify(codec.notRound)); |
| 247 | check('and no two names become one', |
| 248 | codec.merged.length === 0, JSON.stringify(codec.merged)); |
| 249 | |
| 250 | await shot(s, 'fsnames'); |
| 251 | const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e)); |
| 252 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 253 | await s.close(); |
| 254 | |
| 255 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 256 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 257 | process.exit(bad.length ? 1 : 0); |