@1agh/maude 0.58.1 → 0.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ // The traversal corpus, and the rule that makes the whole thing safe.
2
+ //
3
+ // `syncMeta.path` is hub-supplied input that chooses a DIRECTORY. Every case
4
+ // below is a way a path could try to be something other than "a canvas in this
5
+ // project", plus the one rule that closes the class rather than the instance:
6
+ // the path must slug back to the document carrying it.
7
+
8
+ import { describe, expect, it } from 'bun:test';
9
+
10
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
11
+ import {
12
+ fallbackCanvasPath,
13
+ MAX_CANVAS_PATH_LEN,
14
+ resolveCanvasBodyRel,
15
+ validateCanvasPath,
16
+ } from '../sync/canvas-path.ts';
17
+
18
+ const GROUPS = [{ path: 'system' }, { path: 'ui' }];
19
+
20
+ /** Validate a path AGAINST THE SLUG IT WOULD REALLY CARRY, so a rejection is
21
+ * attributable to the rule under test and not to rule 7 firing first. */
22
+ function check(path: unknown, slug: string) {
23
+ return validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
24
+ }
25
+
26
+ describe('validateCanvasPath — what it accepts', () => {
27
+ it('accepts a genuinely nested canvas', () => {
28
+ const v = check('ui/2026/social/summer-camp.tsx', 'ui-2026-social-summer-camp');
29
+ expect(v).toEqual({ ok: true, rel: 'ui/2026/social/summer-camp.tsx' });
30
+ });
31
+
32
+ it('accepts a canvas directly in a group', () => {
33
+ expect(check('ui/Card.tsx', 'ui-card').ok).toBe(true);
34
+ });
35
+
36
+ it('accepts the design-system group', () => {
37
+ expect(check('system/preview/logo.tsx', 'system-preview-logo').ok).toBe(true);
38
+ });
39
+
40
+ it('accepts a filename with a space — they exist and they sync today', () => {
41
+ // The slug transform maps whitespace to `_`, so `Kanban App.tsx` is a real
42
+ // canvas with a real document. Refusing it here would silently relocate a
43
+ // file that works today.
44
+ expect(check('ui/Kanban App.tsx', 'ui-kanban_app').ok).toBe(true);
45
+ });
46
+
47
+ it('is case-insensitive about the slug, like the slug transform', () => {
48
+ expect(check('ui/Card.tsx', 'UI-CARD').ok).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('validateCanvasPath — the traversal corpus', () => {
53
+ const corpus: Array<[string, unknown, string]> = [
54
+ ['parent traversal', '../../etc/passwd.tsx', 'anything'],
55
+ ['traversal inside a group', 'ui/../../../tmp/x.tsx', 'anything'],
56
+ ['a bare dot component', 'ui/./Card.tsx', 'ui-card'],
57
+ ['absolute posix', '/etc/cron.d/x.tsx', 'anything'],
58
+ ['windows drive', 'C:\\Windows\\x.tsx', 'anything'],
59
+ ['UNC share', '\\\\host\\share\\x.tsx', 'anything'],
60
+ ['a backslash smuggling a separator', 'ui\\..\\..\\x.tsx', 'anything'],
61
+ ['an empty component', 'ui//Card.tsx', 'ui-card'],
62
+ ['a trailing slash', 'ui/Card.tsx/', 'ui-card'],
63
+ ['a NUL byte', 'ui/Card.tsx\u0000.png', 'ui-card'],
64
+ ['a newline', 'ui/Ca\nrd.tsx', 'ui-card'],
65
+ ['percent-encoded traversal', 'ui/%2e%2e/%2e%2e/x.tsx', 'anything'],
66
+ ['a unicode dot lookalike as a component', 'ui/\u2024\u2024/x.tsx', 'anything'],
67
+ ['a bidi override in a component', 'ui/Ca\u202erd.tsx', 'ui-card'],
68
+ ['not a .tsx', 'ui/Card.html', 'ui-card'],
69
+ ['no extension at all', 'ui/Card', 'ui-card'],
70
+ ['an extra extension', 'ui/Card.tsx.png', 'ui-card'],
71
+ ['a dotfile', 'ui/.env.tsx', 'anything'],
72
+ ['at the design root, in no group', 'ui-card.tsx', 'ui-card'],
73
+ ['outside every declared group', 'assets/Card.tsx', 'assets-card'],
74
+ ['not a string', 42, 'anything'],
75
+ ['an object', { toString: () => 'ui/Card.tsx' }, 'ui-card'],
76
+ ['the empty string', '', 'anything'],
77
+ ];
78
+
79
+ for (const [label, path, slug] of corpus) {
80
+ it(`refuses ${label}`, () => {
81
+ const v = validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
82
+ expect(v.ok).toBe(false);
83
+ });
84
+ }
85
+
86
+ it('refuses a path longer than the cap', () => {
87
+ const long = `ui/${'a'.repeat(MAX_CANVAS_PATH_LEN)}.tsx`;
88
+ expect(validateCanvasPath({ path: long, slug: 'x', canvasGroups: GROUPS }).ok).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('rule 7 — a path that points elsewhere no longer addresses this document', () => {
93
+ it('refuses a well-formed path belonging to a DIFFERENT canvas', () => {
94
+ // The one that matters. Every rule above refuses a MALFORMED path; this
95
+ // refuses a perfectly legal one that simply is not this document's. Without
96
+ // it, any peer could overwrite any other canvas in the project by pushing a
97
+ // body under its own document name and someone else's path.
98
+ const v = check('ui/secrets/credentials.tsx', 'ui-card');
99
+ expect(v.ok).toBe(false);
100
+ if (!v.ok) expect(v.reason).toContain('slugs to');
101
+ });
102
+
103
+ it('refuses a path that only differs in a folder boundary', () => {
104
+ // `ui/a-b.tsx` and `ui/a/b.tsx` slug identically (`ui-a-b`) — so this pair
105
+ // is ACCEPTED by rule 7 on purpose: the collision is the slug's, it predates
106
+ // this feature, and `admitCanvases` already refuses to sync both files.
107
+ expect(check('ui/a/b.tsx', 'ui-a-b').ok).toBe(true);
108
+ expect(check('ui/a-b.tsx', 'ui-a-b').ok).toBe(true);
109
+ // What is NOT accepted is a path whose slug simply differs.
110
+ expect(check('ui/a/c.tsx', 'ui-a-b').ok).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('fallbackCanvasPath — visible, and still the same document', () => {
115
+ it('places an un-pathed canvas inside the group its slug came from', () => {
116
+ expect(fallbackCanvasPath('ui-legacy', GROUPS)).toBe('ui/legacy.tsx');
117
+ });
118
+
119
+ it('the result slugs BACK to the document — a fallback must not fork it', () => {
120
+ // The property, not the string. `ui/ui-legacy.tsx` would be visible and
121
+ // would slug to `ui-ui-legacy`: a second document syncing the same bytes,
122
+ // with the original orphaned on the hub.
123
+ for (const slug of ['ui-legacy', 'ui-2026-social-summer-camp', 'system-preview-logo']) {
124
+ expect(canvasSlugFromRel(fallbackCanvasPath(slug, GROUPS), '.design')).toBe(slug);
125
+ }
126
+ });
127
+
128
+ it('picks the group the slug actually came from, not the first one', () => {
129
+ const groups = [{ path: 'ui' }, { path: 'screens' }];
130
+ expect(fallbackCanvasPath('screens-x', groups)).toBe('screens/x.tsx');
131
+ });
132
+
133
+ it('prefers the LONGEST matching group over declaration order', () => {
134
+ const groups = [{ path: 'ui' }, { path: 'ui/social' }];
135
+ expect(fallbackCanvasPath('ui-social-x', groups)).toBe('ui/social/x.tsx');
136
+ });
137
+
138
+ it('keeps the design root when no declared group matches the slug', () => {
139
+ // Today's behaviour, deliberately: it is invisible, but it is the SAME
140
+ // document. A canvas that was outside every group at its author cannot be
141
+ // placed inside one without renaming it — which is what a path is for.
142
+ expect(fallbackCanvasPath('welcome', GROUPS)).toBe('welcome.tsx');
143
+ expect(fallbackCanvasPath('x', [])).toBe('x.tsx');
144
+ });
145
+
146
+ it('drops a group path that escapes the design root', () => {
147
+ expect(fallbackCanvasPath('etc-x', [{ path: '../../etc' }])).toBe('etc-x.tsx');
148
+ });
149
+
150
+ it('never produces a separator, a dotfile or a `..` from the slug', () => {
151
+ // The slug is charset-constrained upstream (`slugFromDocName`), so this is
152
+ // the second line rather than the first — but it is the line that stands
153
+ // between a bad slug and a directory of the sender's choosing.
154
+ for (const hostile of ['../../evil', '/etc/passwd', '..', '.', 'a\\b', '']) {
155
+ const rel = fallbackCanvasPath(hostile, GROUPS);
156
+ expect(rel).not.toContain('/');
157
+ expect(rel).not.toContain('\\');
158
+ expect(rel).not.toContain('..');
159
+ expect(rel.startsWith('.')).toBe(false);
160
+ expect(rel.endsWith('.tsx')).toBe(true);
161
+ }
162
+ });
163
+ });
164
+
165
+ describe('resolveCanvasBodyRel — one answer for "what does a refused path do"', () => {
166
+ it('uses the path when it validates', () => {
167
+ expect(
168
+ resolveCanvasBodyRel({
169
+ path: 'ui/2026/social/summer-camp.tsx',
170
+ slug: 'ui-2026-social-summer-camp',
171
+ designRel: '.design',
172
+ canvasGroups: GROUPS,
173
+ })
174
+ ).toEqual({ rel: 'ui/2026/social/summer-camp.tsx', fromPath: true });
175
+ });
176
+
177
+ it('falls back — never throws the canvas away — and says why', () => {
178
+ const reasons: string[] = [];
179
+ const out = resolveCanvasBodyRel({
180
+ path: '../../etc/passwd.tsx',
181
+ slug: 'ui-card',
182
+ canvasGroups: GROUPS,
183
+ onRefused: (r) => reasons.push(r),
184
+ });
185
+ expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
186
+ expect(reasons.length).toBe(1);
187
+ });
188
+
189
+ it('an ABSENT path is not a refusal — an older peer omits it', () => {
190
+ const reasons: string[] = [];
191
+ const out = resolveCanvasBodyRel({
192
+ path: undefined,
193
+ slug: 'ui-card',
194
+ canvasGroups: GROUPS,
195
+ onRefused: (r) => reasons.push(r),
196
+ });
197
+ expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
198
+ expect(reasons).toEqual([]);
199
+ });
200
+ });
@@ -0,0 +1,402 @@
1
+ // Pulling a project into an EMPTY folder, through the real sync runtime.
2
+ //
3
+ // The unit tests around `canvas-path.ts` prove the rules; `apps/hub/test/`
4
+ // proves the two receivers. This proves the wiring between them on the desktop
5
+ // side — that the path is read from the DOCUMENT (not the listing, which cannot
6
+ // carry it), that the agent is built around the corrected path rather than the
7
+ // provisional one, that the canvas is written where the tree will find it, and
8
+ // that a folder with no `config.json` ends up with one describing what arrived.
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ symlinkSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { Awareness } from 'y-protocols/awareness';
24
+ import * as Y from 'yjs';
25
+
26
+ import type { Context, DevServerConfig } from '../context.ts';
27
+ import { createBus } from '../context.ts';
28
+ import { createSyncRuntime, type SyncProvider } from '../sync/index.ts';
29
+
30
+ const URL_ = 'https://hub.example.com';
31
+ const NESTED_REL = 'ui/2026/social/summer-camp.tsx';
32
+ const NESTED_SLUG = 'ui-2026-social-summer-camp';
33
+ const BODY = 'export default () => <main>summer camp</main>;\n';
34
+
35
+ let dir: string;
36
+ let cfgPathEnv: string | undefined;
37
+ let realFetch: typeof fetch;
38
+
39
+ beforeEach(() => {
40
+ dir = mkdtempSync(join(tmpdir(), 'sync-path-pull-'));
41
+ cfgPathEnv = process.env.HUBS_CONFIG_PATH;
42
+ process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
43
+ writeFileSync(
44
+ process.env.HUBS_CONFIG_PATH,
45
+ JSON.stringify({ hubs: { [URL_]: { token: 'mau_test', linkedAt: 1 } } })
46
+ );
47
+ chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
48
+ realFetch = globalThis.fetch;
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = realFetch;
53
+ if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
54
+ else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
55
+ rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ /** The hub's `GET /api/documents` — names and byte counts, never a path. */
59
+ function hubListing(documents: Array<{ name: string; bytes: number }>): void {
60
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
61
+ if (String(input).endsWith('/api/documents')) {
62
+ return new Response(JSON.stringify({ documents }), {
63
+ status: 200,
64
+ headers: { 'content-type': 'application/json' },
65
+ });
66
+ }
67
+ return new Response('nope', { status: 404 });
68
+ }) as typeof fetch;
69
+ }
70
+
71
+ function makeCtx(canvasGroups?: DevServerConfig['canvasGroups']): Context {
72
+ const designRoot = join(dir, 'design');
73
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
74
+ return {
75
+ canvasOrigin: 'http://canvas.localhost:1234',
76
+ cfg: {
77
+ name: 'test',
78
+ projectLabel: null,
79
+ designRoot: 'design',
80
+ canvasGroups: canvasGroups ?? [
81
+ { label: 'Design system', path: 'system' },
82
+ { label: 'Canvases', path: 'ui' },
83
+ ],
84
+ rootClass: 'app',
85
+ themeDefault: 'dark',
86
+ tokensCssRel: 'system/colors.css',
87
+ teamAccentDefault: null,
88
+ handoffTargets: [],
89
+ newCanvasDir: 'ui',
90
+ newComponentDir: 'ui/components',
91
+ linkedHub: { url: URL_, linkedAt: 1 },
92
+ _source: 'defaults',
93
+ },
94
+ projectLabel: 'test',
95
+ paths: {
96
+ repoRoot: dir,
97
+ designRel: 'design',
98
+ designRoot,
99
+ serverInfoFile: join(designRoot, '_server.json'),
100
+ activeFile: join(designRoot, '_active.json'),
101
+ commentsDir: join(designRoot, '_comments'),
102
+ canvasStateDir: join(designRoot, '_canvas-state'),
103
+ historyDir: join(designRoot, '_history'),
104
+ tokensUrlRel: 'design/system/colors.css',
105
+ systemDirRel: 'system',
106
+ },
107
+ bus: createBus(),
108
+ } as unknown as Context;
109
+ }
110
+
111
+ /**
112
+ * A provider whose document ALREADY holds what the hub holds — the body plus
113
+ * whatever `syncMeta.path` the peer that authored it stamped. That is the shape
114
+ * a real pull sees at `onceSynced`, and the reason the target cannot be decided
115
+ * from the listing.
116
+ */
117
+ function hubDocProviderFactory(
118
+ seed: Record<string, { body: string; path?: string; css?: string }>,
119
+ docs?: Map<string, Y.Doc>
120
+ ) {
121
+ return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
122
+ const doc = args.document ?? new Y.Doc();
123
+ const slug = args.documentName.split('/').pop() as string;
124
+ const entry = seed[slug];
125
+ if (entry) {
126
+ doc.transact(() => {
127
+ doc.getText('html').insert(0, entry.body);
128
+ if (entry.path) doc.getMap('syncMeta').set('path', entry.path);
129
+ if (entry.css) doc.getText('css').insert(0, entry.css);
130
+ }, 'hub');
131
+ }
132
+ docs?.set(slug, doc);
133
+ return {
134
+ document: doc,
135
+ awareness: new Awareness(doc),
136
+ async onceSynced() {},
137
+ destroy() {},
138
+ };
139
+ };
140
+ }
141
+
142
+ describe('a peer with an empty design root pulls the project down whole', () => {
143
+ test('the canvas lands at the path its author gave it', async () => {
144
+ hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
145
+ const ctx = makeCtx();
146
+ const runtime = createSyncRuntime(ctx, {
147
+ providerFactory: hubDocProviderFactory({
148
+ [NESTED_SLUG]: { body: BODY, path: NESTED_REL },
149
+ }),
150
+ });
151
+ await runtime?.start();
152
+ await runtime?.stop();
153
+
154
+ const wanted = join(ctx.paths.designRoot, NESTED_REL);
155
+ expect(existsSync(wanted)).toBe(true);
156
+ expect(readFileSync(wanted, 'utf8')).toBe(BODY);
157
+ // And NOT flat at the design root, where nothing would list it.
158
+ expect(existsSync(join(ctx.paths.designRoot, `${NESTED_SLUG}.tsx`))).toBe(false);
159
+ });
160
+
161
+ test('a document with NO path still arrives, inside a canvas group', async () => {
162
+ hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
163
+ const ctx = makeCtx();
164
+ const runtime = createSyncRuntime(ctx, {
165
+ providerFactory: hubDocProviderFactory({
166
+ 'ui-legacy': { body: 'export default () => null;\n' },
167
+ }),
168
+ });
169
+ await runtime?.start();
170
+ await runtime?.stop();
171
+
172
+ // `ui/legacy.tsx`, not `ui/ui-legacy.tsx` — the latter would be visible and
173
+ // would slug to a DIFFERENT document.
174
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
175
+ });
176
+
177
+ test('a hostile path is refused and the canvas still arrives', async () => {
178
+ hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
179
+ const ctx = makeCtx();
180
+ const runtime = createSyncRuntime(ctx, {
181
+ providerFactory: hubDocProviderFactory({
182
+ 'ui-legacy': { body: 'x\n', path: '../../../../tmp/pwned.tsx' },
183
+ }),
184
+ });
185
+ await runtime?.start();
186
+ await runtime?.stop();
187
+
188
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
189
+ expect(existsSync(join(dir, '..', 'tmp', 'pwned.tsx'))).toBe(false);
190
+ });
191
+
192
+ test('a fresh link accepts the author’s OWN group, and writes it down', async () => {
193
+ // `config.json` is not part of the sync lane, so a bare folder runs on the
194
+ // DEFAULT groups. Without the fresh-link relaxation a project whose author
195
+ // calls their group `screens` would have every path refused and arrive
196
+ // invisible — the empty-folder case, in the shape it actually takes.
197
+ hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
198
+ const ctx = makeCtx();
199
+ const runtime = createSyncRuntime(ctx, {
200
+ providerFactory: hubDocProviderFactory({
201
+ 'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
202
+ }),
203
+ });
204
+ await runtime?.start();
205
+ await runtime?.stop();
206
+
207
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(true);
208
+ // …and the project now declares itself, so the next boot needs no relaxation.
209
+ const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
210
+ expect(cfg.canvasGroups.map((g: { path: string }) => g.path)).toContain('screens');
211
+ });
212
+
213
+ test('a project that already declared itself is NEVER overwritten', async () => {
214
+ const ctx = makeCtx();
215
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
216
+ const cfgFile = join(ctx.paths.designRoot, 'config.json');
217
+ const mine = `${JSON.stringify({ name: 'mine', canvasGroups: [{ label: 'UI', path: 'ui' }] })}\n`;
218
+ writeFileSync(cfgFile, mine);
219
+ hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
220
+
221
+ const runtime = createSyncRuntime(ctx, {
222
+ providerFactory: hubDocProviderFactory({
223
+ 'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
224
+ }),
225
+ });
226
+ await runtime?.start();
227
+ await runtime?.stop();
228
+
229
+ expect(readFileSync(cfgFile, 'utf8')).toBe(mine);
230
+ // The undeclared group is refused, so the canvas falls back — still arriving.
231
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(false);
232
+ expect(existsSync(join(ctx.paths.designRoot, 'screens-home.tsx'))).toBe(true);
233
+ });
234
+ });
235
+
236
+ describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
237
+ test('a pulled canvas never lands on a file that already exists locally', async () => {
238
+ // `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
239
+ // a security opt-out a hub must not be able to flip. Omitted means absent
240
+ // from `localCanvases`, which means the hub's document for that slug is
241
+ // classified hub-ONLY and pulled. Before the path lane that was benign: the
242
+ // body landed flat at the design root, loaded by nothing. Honouring a remote
243
+ // path would land it on the real file.
244
+ const ctx = makeCtx();
245
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
246
+ const guarded = join(ctx.paths.designRoot, 'ui', 'Card.tsx');
247
+ writeFileSync(guarded, 'export default () => <b>MINE</b>;\n');
248
+ writeFileSync(
249
+ join(ctx.paths.designRoot, 'ui', 'Card.meta.json'),
250
+ JSON.stringify({ syncable: false })
251
+ );
252
+ hubListing([{ name: 'ws/acme/main/ui-card', bytes: 10 }]);
253
+
254
+ const docs = new Map<string, Y.Doc>();
255
+ const runtime = createSyncRuntime(ctx, {
256
+ providerFactory: hubDocProviderFactory(
257
+ { 'ui-card': { body: 'export default () => <b>HUB</b>;\n', path: 'ui/Card.tsx' } },
258
+ docs
259
+ ),
260
+ });
261
+ await runtime?.start();
262
+
263
+ // The boot moment is not the whole story — the provider now OWNS a path, so
264
+ // every later hub edit flows to it. Push one, the way a hub actually would.
265
+ const doc = docs.get('ui-card');
266
+ if (doc) {
267
+ const text = doc.getText('html');
268
+ doc.transact(() => {
269
+ text.delete(0, text.length);
270
+ text.insert(0, 'export default () => <b>PWNED</b>;\n');
271
+ }, 'hub');
272
+ }
273
+ await new Promise((r) => setTimeout(r, 250));
274
+ await runtime?.stop();
275
+
276
+ expect(readFileSync(guarded, 'utf8')).toContain('MINE');
277
+ });
278
+
279
+ test('a pulled canvas cannot overwrite the served token stylesheet', async () => {
280
+ // `system` is a DEFAULT canvas group and the `.css` sibling is derived from
281
+ // the body path, so `system/colors_and_type.tsx` satisfies every rule and
282
+ // writes its css lane exactly where `tokensCssRel` is served from.
283
+ const ctx = makeCtx();
284
+ mkdirSync(join(ctx.paths.designRoot, 'system'), { recursive: true });
285
+ const tokens = join(ctx.paths.designRoot, 'system', 'colors.css');
286
+ writeFileSync(tokens, ':root{--bg-0:#fff}\n');
287
+ hubListing([{ name: 'ws/acme/main/system-colors', bytes: 10 }]);
288
+
289
+ const runtime = createSyncRuntime(ctx, {
290
+ providerFactory: hubDocProviderFactory({
291
+ 'system-colors': {
292
+ body: 'x\n',
293
+ path: 'system/colors.tsx',
294
+ // The lane that does the damage — it is written to the body path's
295
+ // `.css` sibling, which here IS the served `tokensCssRel`.
296
+ css: ':root{--bg-0:url("https://evil.example/?x")}\n',
297
+ },
298
+ }),
299
+ });
300
+ await runtime?.start();
301
+ await runtime?.stop();
302
+
303
+ expect(readFileSync(tokens, 'utf8')).toBe(':root{--bg-0:#fff}\n');
304
+ });
305
+
306
+ test('containment is checked THROUGH symlinks, which resolve() cannot see', async () => {
307
+ const ctx = makeCtx();
308
+ const outside = mkdtempSync(join(tmpdir(), 'sync-path-outside-'));
309
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
310
+ symlinkSync(outside, join(ctx.paths.designRoot, 'ui', 'escape'), 'dir');
311
+ hubListing([{ name: 'ws/acme/main/ui-escape-pwned', bytes: 10 }]);
312
+
313
+ const runtime = createSyncRuntime(ctx, {
314
+ providerFactory: hubDocProviderFactory({
315
+ 'ui-escape-pwned': { body: 'x\n', path: 'ui/escape/pwned.tsx' },
316
+ }),
317
+ });
318
+ await runtime?.start();
319
+ await runtime?.stop();
320
+
321
+ expect(existsSync(join(outside, 'pwned.tsx'))).toBe(false);
322
+ rmSync(outside, { recursive: true, force: true });
323
+ });
324
+ });
325
+
326
+ describe('the untrusted markers name the path the body actually lands at', () => {
327
+ test('a nested pulled canvas is marked at its REAL path, not the provisional one', async () => {
328
+ // The markers are computed from the descriptor set BEFORE any document has
329
+ // synced — so every pulled entry is still the fallback — and the descriptors
330
+ // are then mutated in place by `relocatePulled`. Marking once left
331
+ // `_untrusted/INDEX.json` and the `.claudeignore` block naming a file that
332
+ // is never created, while the genuinely hub-pushed body sat elsewhere,
333
+ // listed nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
334
+ hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
335
+ const ctx = makeCtx();
336
+ const runtime = createSyncRuntime(ctx, {
337
+ providerFactory: hubDocProviderFactory({
338
+ [NESTED_SLUG]: { body: BODY, path: NESTED_REL },
339
+ }),
340
+ });
341
+ await runtime?.start();
342
+ await runtime?.stop();
343
+
344
+ const index = readFileSync(join(ctx.paths.designRoot, '_untrusted', 'INDEX.json'), 'utf8');
345
+ expect(index).toContain('ui/2026/social/summer-camp.tsx');
346
+ // The provisional fallback was never created, so naming it is naming nothing.
347
+ expect(index).not.toContain('ui/2026-social-summer-camp.tsx');
348
+ });
349
+ });
350
+
351
+ describe('the fresh-link relaxation closes behind itself', () => {
352
+ test('only ONE undeclared group is ever learned', async () => {
353
+ // `freshLink` was a const, so after the first config was written every
354
+ // FURTHER undeclared group was accepted and appended — the relaxation
355
+ // perpetuating itself, and a hub free to plant directories all session.
356
+ hubListing([
357
+ { name: 'ws/acme/main/screens-a', bytes: 10 },
358
+ { name: 'ws/acme/main/vendor-b', bytes: 10 },
359
+ ]);
360
+ const ctx = makeCtx();
361
+ const runtime = createSyncRuntime(ctx, {
362
+ providerFactory: hubDocProviderFactory({
363
+ 'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
364
+ 'vendor-b': { body: 'x\n', path: 'vendor/b.tsx' },
365
+ }),
366
+ });
367
+ await runtime?.start();
368
+ await runtime?.stop();
369
+
370
+ const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
371
+ const learned = cfg.canvasGroups
372
+ .map((g: { path: string }) => g.path)
373
+ .filter((p: string) => p !== 'system' && p !== 'ui');
374
+ expect(learned.length).toBe(1);
375
+ });
376
+
377
+ test('a design root with work in it is NOT a fresh link', async () => {
378
+ // `localCanvases.length === 0` is a fact about the SCAN, which walks only
379
+ // declared groups and applies the syncable + sandbox gates — so a project
380
+ // with real work scans to zero for several innocent reasons, and a hub must
381
+ // not get to author a config.json into it.
382
+ const ctx = makeCtx();
383
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
384
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'Existing.tsx'), 'x\n');
385
+ writeFileSync(
386
+ join(ctx.paths.designRoot, 'ui', 'Existing.meta.json'),
387
+ JSON.stringify({ syncable: false })
388
+ );
389
+ hubListing([{ name: 'ws/acme/main/screens-a', bytes: 10 }]);
390
+
391
+ const runtime = createSyncRuntime(ctx, {
392
+ providerFactory: hubDocProviderFactory({
393
+ 'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
394
+ }),
395
+ });
396
+ await runtime?.start();
397
+ await runtime?.stop();
398
+
399
+ expect(existsSync(join(ctx.paths.designRoot, 'config.json'))).toBe(false);
400
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'a.tsx'))).toBe(false);
401
+ });
402
+ });
@@ -5,6 +5,7 @@
5
5
  // on the hub and no local counter could ever show it.
6
6
 
7
7
  import { describe, expect, test } from 'bun:test';
8
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
8
9
 
9
10
  import {
10
11
  describeRemoteDiff,
@@ -135,7 +136,11 @@ describe('pulling hub-only documents down', () => {
135
136
  expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
136
137
  });
137
138
 
138
- test('lands hub-only canvases flat under the design root', () => {
139
+ test('lands hub-only canvases where the tree can SEE them', () => {
140
+ // Flat, because a slug cannot be un-flattened — but inside the canvas group
141
+ // the slug came from, not at the design root. The tree and `scanCanvases`
142
+ // enumerate `canvasGroups`, so the old design-root target produced a file
143
+ // nobody could see and that never synced onward.
139
144
  const targets = pullTargets(
140
145
  [
141
146
  { name: 'ui-welcome', bytes: 1 },
@@ -147,12 +152,59 @@ describe('pulling hub-only documents down', () => {
147
152
  '/'
148
153
  );
149
154
  expect(targets.map((t) => t.bodyAbs)).toEqual([
150
- '/p/.design/ui-welcome.tsx',
151
- '/p/.design/ui-home.tsx',
155
+ '/p/.design/ui/welcome.tsx',
156
+ '/p/.design/ui/home.tsx',
152
157
  ]);
158
+ // …and each still slugs back to the document it came from. `ui/ui-welcome.tsx`
159
+ // would be visible AND a different document.
160
+ expect(canvasSlugFromRel('ui/welcome.tsx', '.design')).toBe('ui-welcome');
153
161
  // The wire name is kept — the provider must open the document the HUB has,
154
162
  // not a name re-derived from the local path.
155
163
  expect(targets[1].docName).toBe('ws/acme/main/ui-home');
164
+ // No path arrived, so nothing was believed.
165
+ expect(targets.every((t) => t.fromPath === false)).toBe(true);
166
+ });
167
+
168
+ test('uses the path a document carries, once it is known', () => {
169
+ // The listing cannot supply this — it carries names and byte counts only.
170
+ // The runtime re-resolves per document after that document has synced;
171
+ // `pathFor` is that hop, made testable.
172
+ const targets = pullTargets(
173
+ [{ name: 'ws/acme/main/ui-2026-social-summer-camp', bytes: 1 }],
174
+ '/p/.design',
175
+ join,
176
+ resolve,
177
+ '/',
178
+ {
179
+ designRel: '.design',
180
+ canvasGroups: [{ path: 'system' }, { path: 'ui' }],
181
+ pathFor: () => 'ui/2026/social/summer-camp.tsx',
182
+ }
183
+ );
184
+ expect(targets[0].bodyAbs).toBe('/p/.design/ui/2026/social/summer-camp.tsx');
185
+ expect(targets[0].fromPath).toBe(true);
186
+ });
187
+
188
+ test('a refused path degrades to the fallback and says why', () => {
189
+ const refusals: string[] = [];
190
+ const targets = pullTargets(
191
+ [{ name: 'ui-welcome', bytes: 1 }],
192
+ '/p/.design',
193
+ join,
194
+ resolve,
195
+ '/',
196
+ {
197
+ designRel: '.design',
198
+ canvasGroups: [{ path: 'ui' }],
199
+ // A perfectly well-formed path that simply is not this document's —
200
+ // the case rule 7 exists for.
201
+ pathFor: () => 'ui/somebody-elses-canvas.tsx',
202
+ onRefused: (slug, reason) => refusals.push(`${slug}: ${reason}`),
203
+ }
204
+ );
205
+ expect(targets[0].bodyAbs).toBe('/p/.design/ui/welcome.tsx');
206
+ expect(targets[0].fromPath).toBe(false);
207
+ expect(refusals.length).toBe(1);
156
208
  });
157
209
 
158
210
  test('a document NAME can never write outside the design root', () => {