Oregami
Repositories/oxedyne/daimond

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.
26import { open, signInAs, shot } from './harness.mjs';
27
28const ok = [], bad = [];
29const check = (name, pass, detail) => {
30 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
31 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
32};
33
34const MAILDIR = 'mail/alice@test.local/INBOX/cur';
35const MSG = '70074.3.daimond:2,S';
36const BODY = 'From: a@b.test\r\nSubject: the colon stays\r\n\r\nbody\r\n';
37
38const s = await open({ name: 'fsnames', connect: false });
39const p = s.page;
40await 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
46const 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.
70const 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});
79check('the strict-root stand-in is armed', armed === 'armed', armed);
80check('and it refuses a colon in the browser’s own words',
81 /Name is not allowed/.test(instrument.refused), instrument.refused);
82check('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
89const 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 });
97check('a Maildir message can be written at all', wrote === 'ok', wrote.slice(0, 200));
98
99const 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 });
105check('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.
109const 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 });
115check('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.
119const 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 });
126check('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.
135await p.reload({ waitUntil: 'domcontentloaded' });
136await signInAs(s, 'fsnames');
137await p.waitForTimeout(1500);
138
139const LEGACY_DIR = 'mail/bob@test.local/INBOX/cur';
140const LEGACY = '90001.7.daimond:2,';
141const LEGACY_BODY = 'From: legacy@b.test\r\n\r\nwritten before the codec\r\n';
142
143const 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 });
154check('this sandbox can hold a legacy name at all, so the case is real',
155 seeded === 'ok', seeded);
156
157const 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 });
166check('a message stored before the codec is still readable, unmoved',
167 legacyRead === LEGACY_BODY, legacyRead.slice(0, 90).replace(/\r?\n/g, '⏎'));
168
169const 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 });
179check('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));
182check('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.
186const 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});
196check('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
205const 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});
243check('the Rust codec and the JavaScript one agree, name for name',
244 codec.disagree.length === 0, codec.n + ' names · ' + JSON.stringify(codec.disagree));
245check('and both are reversible over the same corpus',
246 codec.notRound.length === 0, JSON.stringify(codec.notRound));
247check('and no two names become one',
248 codec.merged.length === 0, JSON.stringify(codec.merged));
249
250await shot(s, 'fsnames');
251const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e));
252console.log('\nconsole errors:', errs.slice(0, 4));
253await s.close();
254
255console.log(`\n${ok.length} passed, ${bad.length} failed`);
256if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
257process.exit(bad.length ? 1 : 0);