Oregami
Repositories/oxedyne/daimond

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
44import { open } from './harness.mjs';
45
46const ok = [], bad = [];
47const check = (name, pass, detail) => {
48 (pass ? ok : bad).push(name);
49 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
50};
51
52const FOLDER = 'standin-folder'; // an OPFS subdirectory standing in for a picked folder
53const ADDR = 'alice@example.com';
54const BOX = 'mail/' + ADDR + '/INBOX/cur';
55const MSG = '70074.3.daimond:2,S'; // the Maildir name, colon and all
56const MSG2 = '70075.3.daimond:2,';
57const RAW = 'From: bob@example.net\r\nSubject: in the folder\r\n\r\nbody\r\n';
58
59const s = await open({ name: 'mailroot', connect: false });
60const p = s.page;
61await 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.
65const 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
116await install();
117
118const tool = (name, args) => p.evaluate(([n, a]) => __tool(n, a), [name, args]);
119const at = (which, path) => p.evaluate(([w, x]) => __at(w, x), [which, path]);
120const names = (which, path) => p.evaluate(([w, x]) => __names(w, x), [which, path]);
121const put = (which, path, body) => p.evaluate(([w, x, b]) => __put(w, x, b), [which, path, body]);
122const toFolder = () => p.evaluate(() => __d.mod.set_workspace_dir(__d.folder));
123const toBrowser = () => p.evaluate(() => __d.mod.use_opfs_workspace());
124const 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.
131const RESERVED = '"*:<>?\\|';
132const 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('');
139const 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
146await toFolder();
147await tool('file_write', { path: BOX + '/' + MSG2, content: 'a message synced with a folder open' });
148
149const wroteStore = await names('opfs', BOX);
150const wroteFolder = await names('folder', 'mail');
151check('mail written with a folder open lands in the store',
152 Array.isArray(wroteStore) && wroteStore.some((n) => dec(n) === MSG2),
153 JSON.stringify(wroteStore));
154check('and not one byte of it in the user\'s folder',
155 wroteFolder === null, JSON.stringify(wroteFolder));
156check('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.
161await toBrowser();
162check('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.
167const 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});
173check('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));
177await 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
184await tool('file_write', { path: 'src/main.rs', content: 'fn main() {}' });
185await tool('file_write', { path: 'mailbox.md', content: 'my notes about mail' });
186await tool('file_write', { path: 'mail-old/keep.eml', content: 'an archive of theirs' });
187await tool('file_write', { path: 'src/mail/client.rs', content: 'their own mail client' });
188check("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')));
191check('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
206const FOLDER_MSG = 'mail/' + ADDR + '/INBOX/cur/' + enc(MSG);
207const DRAFT = 'mail/' + ADDR + '/drafts/draft-7.eml';
208const HELD = 'mail/' + ADDR + '/drafts/draft-9.eml';
209await put('folder', FOLDER_MSG, RAW);
210await put('folder', DRAFT, 'From: ' + ADDR + '\r\nSubject: written by the daimon\r\n\r\nplease send\r\n');
211await 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.
213await 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.
216await put('folder', 'mail/archive-2019/letter.txt', 'the user\'s own correspondence');
217
218const report = await adopt();
219check('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));
222check('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
227const readBack = await tool('file_read', { path: BOX + '/' + MSG });
228check('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));
230check('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));
233check('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));
236check('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
249const inbox = await names('opfs', BOX);
250const spellings = {};
251(inbox || []).forEach((n) => { (spellings[dec(n)] = spellings[dec(n)] || []).push(n); });
252const doubled = Object.keys(spellings).filter((k) => spellings[k].length > 1);
253check('no message exists under two spellings of its own name',
254 Array.isArray(inbox) && doubled.length === 0,
255 JSON.stringify(spellings));
256check('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
263const before = JSON.stringify(await names('opfs', BOX));
264const second = await adopt();
265const after = JSON.stringify(await names('opfs', BOX));
266check('running the migration again brings nothing home',
267 second.mail && second.mail.copied === 0, JSON.stringify(second.mail));
268check('and leaves the store exactly as it was', before === after, before + ' | ' + after);
269check('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.
276await 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]);
285const third = await adopt();
286check('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
293await toBrowser();
294const none = await adopt();
295check('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));
298const settled = (await names('opfs', BOX) || []).map(dec).sort();
299check('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
310const 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 });
321await toFolder();
322if (viaPanel) {
323 await tool('file_write', { path: viaPanel + '/cur/probe', content: 'through the panel\'s own path' });
324}
325check("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.
333const noise = s.errs.filter((e) =>
334 !/favicon|ERR_ABORTED|net::ERR|Failed to load resource/i.test(e));
335check('the page threw nothing along the way', noise.length === 0, noise.slice(0, 3).join(' | '));
336
337console.log('\n ' + ok.length + ' ok, ' + bad.length + ' failed');
338if (bad.length) console.log(' failed: ' + bad.join(', '));
339await s.close();
340process.exit(bad.length ? 1 : 0);