oxedyne/daimond/dev/verify_mailroot.mjs
17.8 KiB, 1 run
created by r2519314175:525, 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_mailroot.mjs — a mailbox belongs to the account, not to whichever folder is open. |
| 2 | // |
| 3 | // `mail/<address>/…` was NOT one of Daimond's own roots, so every path the mail |
| 4 | // client used resolved against the workspace root like a file of the user's: |
| 5 | // sync with folder A open and the messages landed inside A, close it and Daimond |
| 6 | // read the sandbox and found nothing, open folder B and they went somewhere else |
| 7 | // again. A mailbox is per ACCOUNT, so that was wrong in all three directions — |
| 8 | // and a real folder would not even take the names, because a Maildir file is |
| 9 | // called `70074.3.daimond:2,S` and nothing outside a browser's sandbox accepts |
| 10 | // the colon. |
| 11 | // |
| 12 | // The rule is now one line, in the same place the Diamond store's is |
| 13 | // (`is_store_path`, src/tools.rs; `resolve_root`, src/wasm/opfs.rs), and the |
| 14 | // messages an earlier build stranded in a folder are COPIED home on the next |
| 15 | // folder activation and never deleted (`bring_mail_home`, src/wasm/diamond.rs). |
| 16 | // |
| 17 | // What is checked: |
| 18 | // * mail written with a folder open lands in the store and NOT in the folder; |
| 19 | // * it reads back after the folder is closed, and after a switch to another; |
| 20 | // * a message present only in the folder is readable after the migration, |
| 21 | // under the name the mail client asks for — colon and all; |
| 22 | // * a draft is never lost: it comes home, and the folder's copy stays put; |
| 23 | // * a draft ALREADY in the store is not overwritten by the folder's older one; |
| 24 | // * running the migration twice changes nothing, and makes no second copy; |
| 25 | // * no message ends up under two spellings of its own name; |
| 26 | // * with no folder ever opened the migration is a no-op, which is the common |
| 27 | // case; |
| 28 | // * `mailbox.md`, `mail-old/` and `src/mail/` are the user's work and still |
| 29 | // follow the folder — a fix that pinned everything to the sandbox would pass |
| 30 | // half of the above and destroy real-folder mode. |
| 31 | // |
| 32 | // A real folder needs `showDirectoryPicker()`, a native dialog no harness can |
| 33 | // answer, so an OPFS subdirectory stands in for one — the same stand-in |
| 34 | // dev/verify_droots.mjs and dev/verify_fsa.mjs use, and for the same reason: |
| 35 | // what the picker returns is a FileSystemDirectoryHandle, and OPFS hands out |
| 36 | // that very type. The one thing it cannot reproduce is a real folder REFUSING a |
| 37 | // colon, so the on-disk names below are asserted rather than assumed: what the |
| 38 | // codec does with them is proved in src/fsname.rs's own tests. |
| 39 | // |
| 40 | // Run with dev/serve.mjs (DAIMOND_PORT, default 8777) up. No gateway, no hand, no mock |
| 41 | // model. |
| 42 | // |
| 43 | // node dev/verify_mailroot.mjs |
| 44 | import { open } from './harness.mjs'; |
| 45 | |
| 46 | const ok = [], bad = []; |
| 47 | const check = (name, pass, detail) => { |
| 48 | (pass ? ok : bad).push(name); |
| 49 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 50 | }; |
| 51 | |
| 52 | const FOLDER = 'standin-folder'; // an OPFS subdirectory standing in for a picked folder |
| 53 | const ADDR = 'alice@example.com'; |
| 54 | const BOX = 'mail/' + ADDR + '/INBOX/cur'; |
| 55 | const MSG = '70074.3.daimond:2,S'; // the Maildir name, colon and all |
| 56 | const MSG2 = '70075.3.daimond:2,'; |
| 57 | const RAW = 'From: bob@example.net\r\nSubject: in the folder\r\n\r\nbody\r\n'; |
| 58 | |
| 59 | const s = await open({ name: 'mailroot', connect: false }); |
| 60 | const p = s.page; |
| 61 | await p.waitForTimeout(1500); |
| 62 | |
| 63 | // Helpers installed in the page, so every step below drives the real engine. |
| 64 | // Re-installed after a reload, which takes the page's globals with it. |
| 65 | const install = () => p.evaluate(async (folder) => { |
| 66 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 67 | window.__d = { |
| 68 | mod, |
| 69 | app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true), |
| 70 | root: await navigator.storage.getDirectory(), |
| 71 | folder: null, |
| 72 | }; |
| 73 | __d.folder = await __d.root.getDirectoryHandle(folder, { create: true }); |
| 74 | |
| 75 | // Reach a directory by its ON-DISK components, bypassing every tool: the question |
| 76 | // "where did the bytes land, and under what name" must not be answered by the thing |
| 77 | // under test. `parts` are literal filesystem names, not workspace ones. |
| 78 | const dirAt = async (which, parts, create) => { |
| 79 | let cur = which === 'folder' ? __d.folder : __d.root; |
| 80 | for (const seg of parts) cur = await cur.getDirectoryHandle(seg, { create: !!create }); |
| 81 | return cur; |
| 82 | }; |
| 83 | // Every name a directory actually holds, exactly as the filesystem spells it. |
| 84 | window.__names = async (which, path) => { |
| 85 | try { |
| 86 | const dir = await dirAt(which, path ? path.split('/') : [], false); |
| 87 | const out = []; |
| 88 | for await (const [name] of dir.entries()) out.push(name); |
| 89 | return out.sort(); |
| 90 | } catch (e) { return null; } |
| 91 | }; |
| 92 | // Read a file by its on-disk path. |
| 93 | window.__at = async (which, path) => { |
| 94 | const parts = path.split('/'); |
| 95 | try { |
| 96 | const dir = await dirAt(which, parts.slice(0, -1), false); |
| 97 | const fh = await dir.getFileHandle(parts[parts.length - 1]); |
| 98 | return await (await fh.getFile()).text(); |
| 99 | } catch (e) { return null; } |
| 100 | }; |
| 101 | // Write a file by its on-disk path, which is how a folder left by an earlier build |
| 102 | // is reproduced: that build wrote through the same codec, so the colon arrives |
| 103 | // escaped in a folder and bare in the sandbox. |
| 104 | window.__put = async (which, path, body) => { |
| 105 | const parts = path.split('/'); |
| 106 | const dir = await dirAt(which, parts.slice(0, -1), true); |
| 107 | const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true }); |
| 108 | const w = await fh.createWritable(); |
| 109 | await w.write(body); |
| 110 | await w.close(); |
| 111 | }; |
| 112 | window.__tool = (name, args) => __d.app.run_tool(name, JSON.stringify(args)); |
| 113 | window.__err = (v) => typeof v !== 'string' || /^\s*Error\b/i.test(v); |
| 114 | }, FOLDER); |
| 115 | |
| 116 | await install(); |
| 117 | |
| 118 | const tool = (name, args) => p.evaluate(([n, a]) => __tool(n, a), [name, args]); |
| 119 | const at = (which, path) => p.evaluate(([w, x]) => __at(w, x), [which, path]); |
| 120 | const names = (which, path) => p.evaluate(([w, x]) => __names(w, x), [which, path]); |
| 121 | const put = (which, path, body) => p.evaluate(([w, x, b]) => __put(w, x, b), [which, path, body]); |
| 122 | const toFolder = () => p.evaluate(() => __d.mod.set_workspace_dir(__d.folder)); |
| 123 | const toBrowser = () => p.evaluate(() => __d.mod.use_opfs_workspace()); |
| 124 | const adopt = () => p.evaluate(() => __d.mod.adopt_folder_diamonds().then(JSON.parse)); |
| 125 | |
| 126 | // The escape the filename codec applies to a reserved byte (src/fsname.rs). Written |
| 127 | // out here rather than imported, so the verifier does not agree with the code under |
| 128 | // test by construction. It covers the names a mailbox produces — a Maildir file and a |
| 129 | // `.eml` draft — and not the codec's whole corpus: the rule for a name that already |
| 130 | // carries a `%` is asserted where the codec lives, in src/fsname.rs's own tests. |
| 131 | const RESERVED = '"*:<>?\\|'; |
| 132 | const enc = (name) => name.split('').map((c) => { |
| 133 | const b = c.charCodeAt(0); |
| 134 | if (RESERVED.indexOf(c) > -1 || b < 0x20 || b === 0x7f) { |
| 135 | return '%' + b.toString(16).toUpperCase().padStart(2, '0'); |
| 136 | } |
| 137 | return c; |
| 138 | }).join(''); |
| 139 | const dec = (name) => name.replace(/%([0-9A-F]{2})/g, (m, h) => { |
| 140 | const c = String.fromCharCode(parseInt(h, 16)); |
| 141 | return (RESERVED.indexOf(c) > -1 || parseInt(h, 16) < 0x20 || parseInt(h, 16) === 0x7f) ? c : m; |
| 142 | }); |
| 143 | |
| 144 | // ── Mail written while a folder is open ───────────────────────────────── |
| 145 | |
| 146 | await toFolder(); |
| 147 | await tool('file_write', { path: BOX + '/' + MSG2, content: 'a message synced with a folder open' }); |
| 148 | |
| 149 | const wroteStore = await names('opfs', BOX); |
| 150 | const wroteFolder = await names('folder', 'mail'); |
| 151 | check('mail written with a folder open lands in the store', |
| 152 | Array.isArray(wroteStore) && wroteStore.some((n) => dec(n) === MSG2), |
| 153 | JSON.stringify(wroteStore)); |
| 154 | check('and not one byte of it in the user\'s folder', |
| 155 | wroteFolder === null, JSON.stringify(wroteFolder)); |
| 156 | check('and the mail client reads it back through its own path', |
| 157 | /a message synced with a folder open/.test(await tool('file_read', { path: BOX + '/' + MSG2 })), |
| 158 | (await tool('file_read', { path: BOX + '/' + MSG2 })).slice(0, 60)); |
| 159 | |
| 160 | // The whole of the defect: closing the folder used to take the mailbox with it. |
| 161 | await toBrowser(); |
| 162 | check('and it is still there once the folder is closed', |
| 163 | /a message synced with a folder open/.test(await tool('file_read', { path: BOX + '/' + MSG2 })), |
| 164 | (await tool('file_read', { path: BOX + '/' + MSG2 })).slice(0, 60)); |
| 165 | |
| 166 | // A second folder: the mailbox must not move again. |
| 167 | const OTHER = await p.evaluate(async () => { |
| 168 | const h = await __d.root.getDirectoryHandle('standin-other', { create: true }); |
| 169 | __d.other = h; |
| 170 | __d.mod.set_workspace_dir(h); |
| 171 | return true; |
| 172 | }); |
| 173 | check('and still there with a DIFFERENT folder open, which is where it used to go missing', |
| 174 | OTHER && /a message synced with a folder open/.test( |
| 175 | await tool('file_read', { path: BOX + '/' + MSG2 })), |
| 176 | (await tool('file_read', { path: BOX + '/' + MSG2 })).slice(0, 60)); |
| 177 | await toFolder(); |
| 178 | |
| 179 | // ── The user's own work still follows the folder ──────────────────────── |
| 180 | // |
| 181 | // Without this the fix could be "pin everything to the sandbox", which passes |
| 182 | // every check above and destroys real-folder mode. |
| 183 | |
| 184 | await tool('file_write', { path: 'src/main.rs', content: 'fn main() {}' }); |
| 185 | await tool('file_write', { path: 'mailbox.md', content: 'my notes about mail' }); |
| 186 | await tool('file_write', { path: 'mail-old/keep.eml', content: 'an archive of theirs' }); |
| 187 | await tool('file_write', { path: 'src/mail/client.rs', content: 'their own mail client' }); |
| 188 | check("the user's own file goes into the folder, as real-folder mode promises", |
| 189 | (await at('folder', 'src/main.rs')) === 'fn main() {}' && (await at('opfs', 'src/main.rs')) === null, |
| 190 | 'folder: ' + JSON.stringify(await at('folder', 'src/main.rs'))); |
| 191 | check('a path that only resembles the mailbox is still the user\'s work', |
| 192 | (await at('folder', 'mailbox.md')) === 'my notes about mail' |
| 193 | && (await at('folder', 'mail-old/keep.eml')) === 'an archive of theirs' |
| 194 | && (await at('folder', 'src/mail/client.rs')) === 'their own mail client' |
| 195 | && (await at('opfs', 'mailbox.md')) === null |
| 196 | && (await at('opfs', 'mail-old/keep.eml')) === null, |
| 197 | 'mailbox.md in folder: ' + JSON.stringify(await at('folder', 'mailbox.md')) |
| 198 | + ', in store: ' + JSON.stringify(await at('opfs', 'mailbox.md'))); |
| 199 | |
| 200 | // ── What an earlier build left in the folder ──────────────────────────── |
| 201 | // |
| 202 | // The state a user of yesterday's build is actually in: their mailbox sitting in |
| 203 | // their project, under the ESCAPED name a real folder forced on it, and a draft |
| 204 | // an agent wrote that exists nowhere else at all. |
| 205 | |
| 206 | const FOLDER_MSG = 'mail/' + ADDR + '/INBOX/cur/' + enc(MSG); |
| 207 | const DRAFT = 'mail/' + ADDR + '/drafts/draft-7.eml'; |
| 208 | const HELD = 'mail/' + ADDR + '/drafts/draft-9.eml'; |
| 209 | await put('folder', FOLDER_MSG, RAW); |
| 210 | await put('folder', DRAFT, 'From: ' + ADDR + '\r\nSubject: written by the daimon\r\n\r\nplease send\r\n'); |
| 211 | await put('folder', HELD, 'From: ' + ADDR + '\r\nSubject: the folder\'s older copy\r\n\r\nold\r\n'); |
| 212 | // The same draft, already in the store and edited since. The store's copy must win. |
| 213 | await tool('file_write', { path: HELD, content: 'From: ' + ADDR + '\r\nSubject: edited here\r\n\r\nnew\r\n' }); |
| 214 | // A directory of the user's that happens to live under `mail/`: an archive, a |
| 215 | // module, a year's correspondence. It is not a mailbox and is not ours to copy. |
| 216 | await put('folder', 'mail/archive-2019/letter.txt', 'the user\'s own correspondence'); |
| 217 | |
| 218 | const report = await adopt(); |
| 219 | check('the migration took the two files that were missing and not the one that was not', |
| 220 | report.mail && report.mail.copied === 2 && (report.mail.left || []).length === 0, |
| 221 | JSON.stringify(report.mail)); |
| 222 | check('a directory of the user\'s under mail/ is left alone', |
| 223 | (await at('opfs', 'mail/archive-2019/letter.txt')) === null |
| 224 | && (await at('folder', 'mail/archive-2019/letter.txt')) === "the user's own correspondence", |
| 225 | JSON.stringify(await at('opfs', 'mail/archive-2019/letter.txt'))); |
| 226 | |
| 227 | const readBack = await tool('file_read', { path: BOX + '/' + MSG }); |
| 228 | check('a message that was only in the folder now reads under the name mail asks for', |
| 229 | /in the folder/.test(readBack), readBack.slice(0, 80)); |
| 230 | check('and the draft the daimon wrote came with it', |
| 231 | /written by the daimon/.test(await tool('file_read', { path: DRAFT })), |
| 232 | (await tool('file_read', { path: DRAFT })).slice(0, 80)); |
| 233 | check('a draft already in the store was not overwritten by the folder\'s older copy', |
| 234 | /edited here/.test(await tool('file_read', { path: HELD })), |
| 235 | (await tool('file_read', { path: HELD })).slice(0, 80)); |
| 236 | check('and nothing at all was deleted from the folder', |
| 237 | (await at('folder', FOLDER_MSG)) === RAW |
| 238 | && /written by the daimon/.test(await at('folder', DRAFT)) |
| 239 | && /older copy/.test(await at('folder', HELD)), |
| 240 | 'message: ' + JSON.stringify((await at('folder', FOLDER_MSG) || '').slice(0, 20))); |
| 241 | |
| 242 | // ── One message, one name ─────────────────────────────────────────────── |
| 243 | // |
| 244 | // The codec escapes a name a filesystem refuses and prefers an unescaped name |
| 245 | // that is already there (`disk_name`, src/wasm/opfs.rs). A migration that wrote |
| 246 | // the escaped spelling where an unescaped one already sat — or the reverse — |
| 247 | // would leave the same message on disk twice, and the panel would show it twice. |
| 248 | |
| 249 | const inbox = await names('opfs', BOX); |
| 250 | const spellings = {}; |
| 251 | (inbox || []).forEach((n) => { (spellings[dec(n)] = spellings[dec(n)] || []).push(n); }); |
| 252 | const doubled = Object.keys(spellings).filter((k) => spellings[k].length > 1); |
| 253 | check('no message exists under two spellings of its own name', |
| 254 | Array.isArray(inbox) && doubled.length === 0, |
| 255 | JSON.stringify(spellings)); |
| 256 | check('and every message in the store answers to the name the mail client uses', |
| 257 | Array.isArray(inbox) && inbox.length === Object.keys(spellings).length |
| 258 | && [MSG, MSG2].every((m) => Object.keys(spellings).indexOf(m) > -1), |
| 259 | JSON.stringify(Object.keys(spellings))); |
| 260 | |
| 261 | // ── Twice is once ─────────────────────────────────────────────────────── |
| 262 | |
| 263 | const before = JSON.stringify(await names('opfs', BOX)); |
| 264 | const second = await adopt(); |
| 265 | const after = JSON.stringify(await names('opfs', BOX)); |
| 266 | check('running the migration again brings nothing home', |
| 267 | second.mail && second.mail.copied === 0, JSON.stringify(second.mail)); |
| 268 | check('and leaves the store exactly as it was', before === after, before + ' | ' + after); |
| 269 | check('and the message still reads the same afterwards', |
| 270 | /in the folder/.test(await tool('file_read', { path: BOX + '/' + MSG })), |
| 271 | (await tool('file_read', { path: BOX + '/' + MSG })).slice(0, 60)); |
| 272 | |
| 273 | // A run interrupted halfway is a run that copied some and not others, which is |
| 274 | // the state above with one file removed from the store. It must complete, and |
| 275 | // must not touch the ones already home. |
| 276 | await p.evaluate(async ([box, msg]) => { |
| 277 | const parts = (box + '/' + msg).split('/'); |
| 278 | let cur = __d.root; |
| 279 | for (let i = 0; i < parts.length - 1; i++) cur = await cur.getDirectoryHandle(parts[i]); |
| 280 | // Whichever spelling is on disk: the point is that the file is gone. |
| 281 | for await (const [name] of cur.entries()) { |
| 282 | if (name.indexOf(parts[parts.length - 1].split(':')[0]) === 0) await cur.removeEntry(name); |
| 283 | } |
| 284 | }, [BOX, MSG]); |
| 285 | const third = await adopt(); |
| 286 | check('a run interrupted halfway completes on the next activation', |
| 287 | third.mail && third.mail.copied === 1 |
| 288 | && /in the folder/.test(await tool('file_read', { path: BOX + '/' + MSG })), |
| 289 | JSON.stringify(third.mail)); |
| 290 | |
| 291 | // ── The common case: no folder, ever ──────────────────────────────────── |
| 292 | |
| 293 | await toBrowser(); |
| 294 | const none = await adopt(); |
| 295 | check('with no folder open the migration looks at nothing and says so', |
| 296 | none.folder === false && none.mail && none.mail.copied === 0 && none.mail.left.length === 0, |
| 297 | JSON.stringify(none)); |
| 298 | const settled = (await names('opfs', BOX) || []).map(dec).sort(); |
| 299 | check('and mail written in the sandbox stays exactly where it always was', |
| 300 | (await tool('file_read', { path: BOX + '/' + MSG2 })).indexOf('folder open') > -1 |
| 301 | && settled.join('|') === [MSG, MSG2].sort().join('|'), |
| 302 | JSON.stringify(settled)); |
| 303 | |
| 304 | // ── And through the mail client's own idea of where a mailbox is ──────── |
| 305 | // |
| 306 | // Everything above spelled the path itself. `folderDir` is what the panel and |
| 307 | // the sync use, and if it ever stops agreeing with the engine's rule the |
| 308 | // messages go back to following the folder with nothing going red. |
| 309 | |
| 310 | const viaPanel = await p.evaluate(async ({ addr }) => { |
| 311 | if (!window.DaimondMail) return null; |
| 312 | localStorage.setItem('daimond-mail', JSON.stringify({ |
| 313 | accounts: [{ address: addr, host: 'imap.example.com', port: 993, user: addr, folder: 'INBOX' }], |
| 314 | sel: addr, |
| 315 | })); |
| 316 | // `reload` reads the record and then redraws; the panel has never been opened here, |
| 317 | // so a redraw that finds no elements is not what is being asked about. |
| 318 | try { DaimondMail.reload(); } catch (e) { /* the record is loaded either way */ } |
| 319 | return DaimondMail.folderDir(addr, 'INBOX'); |
| 320 | }, { addr: ADDR }); |
| 321 | await toFolder(); |
| 322 | if (viaPanel) { |
| 323 | await tool('file_write', { path: viaPanel + '/cur/probe', content: 'through the panel\'s own path' }); |
| 324 | } |
| 325 | check("the mail client's own path for a mailbox is in the store, not the folder", |
| 326 | !!viaPanel |
| 327 | && (await at('opfs', viaPanel + '/cur/probe')) === "through the panel's own path" |
| 328 | && (await at('folder', viaPanel + '/cur/probe')) === null, |
| 329 | String(viaPanel)); |
| 330 | |
| 331 | // A resource the browser could not load is the dev stack, not the page: no |
| 332 | // gateway runs here, so its probes answer 401 or 502 and neither is a throw. |
| 333 | const noise = s.errs.filter((e) => |
| 334 | !/favicon|ERR_ABORTED|net::ERR|Failed to load resource/i.test(e)); |
| 335 | check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | ')); |
| 336 | |
| 337 | console.log('\n ' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 338 | if (bad.length) console.log(' failed: ' + bad.join(', ')); |
| 339 | await s.close(); |
| 340 | process.exit(bad.length ? 1 : 0); |