@nanobpm/urban 0.63.1 → 0.65.0

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,713 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execFile } from "node:child_process";
4
+ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { isAbsolute, join, dirname, resolve as resolvePath } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { ContextResolver, defaultContextCacheRoot } from "./resolver.ts";
9
+ import { GitSubstrateBackend, hardenedGitArgs, redactUrlUserinfo } from "./git-backend.ts";
10
+ import type { GitRunner } from "./git-backend.ts";
11
+ import type {
12
+ ResolvedContextHandle,
13
+ SubstrateBackend,
14
+ SubstrateResolveOptions,
15
+ } from "./backend.ts";
16
+ import { resolveContextIdentity } from "./identity.ts";
17
+ import type { ContextIdentity } from "./identity.ts";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ /** A backend that records calls instead of touching disk — for sharing tests. */
22
+ class RecordingBackend implements SubstrateBackend {
23
+ readonly calls: Array<{ key: string; localPath: string }> = [];
24
+ async materialise(
25
+ identity: ContextIdentity,
26
+ options: SubstrateResolveOptions,
27
+ ): Promise<ResolvedContextHandle> {
28
+ this.calls.push({ key: identity.key, localPath: options.localPath });
29
+ return {
30
+ identity,
31
+ localPath: options.localPath,
32
+ repo: identity.repo,
33
+ ref: identity.ref,
34
+ };
35
+ }
36
+ }
37
+
38
+ test("resolver memoises per identity — same name resolves once, shared handle", async () => {
39
+ const backend = new RecordingBackend();
40
+ const resolver = new ContextResolver({ cacheRoot: "/tmp/urban-cache", backend });
41
+ const first = await resolver.resolve({ repo: "owner/name", ref: "main" });
42
+ const second = await resolver.resolve({ repo: "owner/name", ref: "main" });
43
+ assert.equal(first, second, "same name returns the identical handle");
44
+ assert.equal(backend.calls.length, 1, "substrate materialised exactly once");
45
+ });
46
+
47
+ test("distinct names resolve to distinct, private substrates", async () => {
48
+ const backend = new RecordingBackend();
49
+ const resolver = new ContextResolver({ cacheRoot: "/tmp/urban-cache", backend });
50
+ const a = await resolver.resolve({ repo: "owner/name", ref: "main" });
51
+ const b = await resolver.resolve({ repo: "owner/other", ref: "main" });
52
+ assert.notEqual(a.localPath, b.localPath);
53
+ assert.equal(backend.calls.length, 2);
54
+ });
55
+
56
+ test("localPath is derived deterministically from the identity slug", async () => {
57
+ const backend = new RecordingBackend();
58
+ const resolver = new ContextResolver({ cacheRoot: "/root", backend });
59
+ const handle = await resolver.resolve({ repo: "owner/name", ref: "main" });
60
+ const identity = resolveContextIdentity({ repo: "owner/name", ref: "main" });
61
+ assert.equal(handle.localPath, join("/root", identity.slug));
62
+ });
63
+
64
+ test("a relative cacheRoot is normalised to an absolute localPath", async () => {
65
+ const backend = new RecordingBackend();
66
+ const resolver = new ContextResolver({ cacheRoot: "relative/cache", backend });
67
+ const handle = await resolver.resolve({ repo: "owner/name", ref: "main" });
68
+ assert.equal(isAbsolute(resolver.cacheRoot), true);
69
+ assert.equal(isAbsolute(handle.localPath), true);
70
+ const identity = resolveContextIdentity({ repo: "owner/name", ref: "main" });
71
+ assert.equal(handle.localPath, join(resolvePath("relative/cache"), identity.slug));
72
+ });
73
+
74
+ test("defaultContextCacheRoot honours URBAN_CONTEXT_CACHE_DIR then XDG", () => {
75
+ assert.equal(
76
+ defaultContextCacheRoot({ URBAN_CONTEXT_CACHE_DIR: "/explicit" }),
77
+ "/explicit",
78
+ );
79
+ assert.equal(
80
+ defaultContextCacheRoot({ XDG_CACHE_HOME: "/xdg" }),
81
+ join("/xdg", "urban", "context"),
82
+ );
83
+ });
84
+
85
+ /** A backend whose `materialise` blocks until released — lets tests observe how
86
+ * many materialisations run concurrently for one identity. */
87
+ class GatedBackend implements SubstrateBackend {
88
+ calls = 0;
89
+ concurrent = 0;
90
+ maxConcurrent = 0;
91
+ #release!: () => void;
92
+ readonly gate = new Promise<void>((resolve) => {
93
+ this.#release = resolve;
94
+ });
95
+ release(): void {
96
+ this.#release();
97
+ }
98
+ async materialise(
99
+ identity: ContextIdentity,
100
+ options: SubstrateResolveOptions,
101
+ ): Promise<ResolvedContextHandle> {
102
+ this.calls += 1;
103
+ this.concurrent += 1;
104
+ this.maxConcurrent = Math.max(this.maxConcurrent, this.concurrent);
105
+ await this.gate;
106
+ this.concurrent -= 1;
107
+ return {
108
+ identity,
109
+ localPath: options.localPath,
110
+ repo: identity.repo,
111
+ ref: identity.ref,
112
+ };
113
+ }
114
+ }
115
+
116
+ test("concurrent resolves coalesce onto one in-flight materialisation, even with refresh:true", async () => {
117
+ const backend = new GatedBackend();
118
+ const resolver = new ContextResolver({ cacheRoot: "/tmp/urban-cache", backend });
119
+ const binding = { repo: "owner/name", ref: "main" };
120
+ // Fire a default resolve and a refresh:true resolve while the first is still
121
+ // in flight — they must share the single materialisation, never double-clone.
122
+ const p1 = resolver.resolve(binding);
123
+ const p2 = resolver.resolve(binding, { refresh: true });
124
+ backend.release();
125
+ const [a, b] = await Promise.all([p1, p2]);
126
+ assert.equal(backend.calls, 1, "materialise ran exactly once");
127
+ assert.equal(backend.maxConcurrent, 1, "never two materialisations in flight");
128
+ assert.equal(a, b, "both resolves share the same handle");
129
+ });
130
+
131
+ test("refresh:true after a settled resolution re-materialises", async () => {
132
+ const backend = new RecordingBackend();
133
+ const resolver = new ContextResolver({ cacheRoot: "/tmp/urban-cache", backend });
134
+ const binding = { repo: "owner/name", ref: "main" };
135
+ await resolver.resolve(binding);
136
+ await resolver.resolve(binding, { refresh: true });
137
+ assert.equal(backend.calls.length, 2, "explicit refresh forces a fresh materialise");
138
+ });
139
+
140
+ // --- Real git resolution (no network: a local temp repo is the substrate) ---
141
+
142
+ test("git backend fails clearly when localPath exists but is not a git working copy", async () => {
143
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-nongit-"));
144
+ try {
145
+ const origin = join(root, "origin");
146
+ const cacheRoot = join(root, "cache");
147
+ await execFileAsync("git", ["init", "-b", "main", origin]);
148
+ await git(origin, "config", "user.email", "t@example.com");
149
+ await git(origin, "config", "user.name", "Test");
150
+ await writeFile(join(origin, "a.txt"), "x\n");
151
+ await git(origin, "add", "a.txt");
152
+ await git(origin, "commit", "-m", "c1");
153
+
154
+ const binding = { repo: origin, ref: "main" };
155
+ const identity = resolveContextIdentity(binding);
156
+ // Squat the target path with a non-git directory.
157
+ const localPath = join(cacheRoot, identity.slug);
158
+ await mkdir(localPath, { recursive: true });
159
+ await writeFile(join(localPath, "stray.txt"), "not a clone\n");
160
+
161
+ const resolver = new ContextResolver({ cacheRoot });
162
+ await assert.rejects(resolver.resolve(binding), /not a git working copy/);
163
+ } finally {
164
+ await rm(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test("git backend fails clearly when localPath exists but is a non-directory (file)", async () => {
169
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-file-"));
170
+ try {
171
+ const cacheRoot = join(root, "cache");
172
+ const binding = { repo: join(root, "origin"), ref: "main" };
173
+ const identity = resolveContextIdentity(binding);
174
+ // Squat the target path with a plain file (not a directory, no `.git`).
175
+ // `git clone` into it would fail with an opaque wrapped error; the backend
176
+ // must detect any pre-existing non-git entry and report it clearly.
177
+ const localPath = join(cacheRoot, identity.slug);
178
+ await mkdir(dirname(localPath), { recursive: true });
179
+ await writeFile(localPath, "i am a file, not a clone\n");
180
+
181
+ const resolver = new ContextResolver({ cacheRoot });
182
+ await assert.rejects(resolver.resolve(binding), /not a git working copy/);
183
+ } finally {
184
+ await rm(root, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test("git backend rejects a symlink at localPath (dangling) — never trusts a link as a clone", async () => {
189
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-symlink-"));
190
+ try {
191
+ const cacheRoot = join(root, "cache");
192
+ const binding = { repo: join(root, "origin"), ref: "main" };
193
+ const identity = resolveContextIdentity(binding);
194
+ // Squat the target path with a symlink to a non-existent target. A symlink AT
195
+ // localPath is rejected outright (via `lstat`, which sees the link itself),
196
+ // because trusting it would make every git op operate on the link's target.
197
+ const localPath = join(cacheRoot, identity.slug);
198
+ await mkdir(dirname(localPath), { recursive: true });
199
+ await symlink(join(root, "does-not-exist"), localPath);
200
+
201
+ const resolver = new ContextResolver({ cacheRoot });
202
+ await assert.rejects(resolver.resolve(binding), /symlink and would escape the cache root/);
203
+ } finally {
204
+ await rm(root, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test("git backend rejects a symlink at localPath pointing to a real working copy (cache escape)", async () => {
209
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-escape-"));
210
+ try {
211
+ const cacheRoot = join(root, "cache");
212
+ const binding = { repo: join(root, "origin"), ref: "main" };
213
+ const identity = resolveContextIdentity(binding);
214
+ // A symlink whose target is a genuine, existing git working copy OUTSIDE the
215
+ // cache root: `join(localPath, ".git")` would resolve through the link and
216
+ // make the backend treat the foreign copy as an already-cloned substrate,
217
+ // running git operations in it. The symlink guard must reject it up front.
218
+ const foreign = join(root, "foreign");
219
+ await mkdir(join(foreign, ".git"), { recursive: true });
220
+ const localPath = join(cacheRoot, identity.slug);
221
+ await mkdir(dirname(localPath), { recursive: true });
222
+ await symlink(foreign, localPath);
223
+
224
+ const resolver = new ContextResolver({ cacheRoot });
225
+ await assert.rejects(resolver.resolve(binding), /symlink and would escape the cache root/);
226
+ } finally {
227
+ await rm(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("git backend rejects a real directory whose .git is a symlink escaping the cache root", async () => {
232
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitlink-"));
233
+ try {
234
+ const cacheRoot = join(root, "cache");
235
+ const binding = { repo: join(root, "origin"), ref: "main" };
236
+ const identity = resolveContextIdentity(binding);
237
+ // `localPath` itself is a genuine directory (passing the symlink-at-localPath
238
+ // guard), but its `.git` is a symlink pointing at a foreign gitdir OUTSIDE the
239
+ // cache root. Trusting it as "already cloned" would run fetch/pin against that
240
+ // out-of-cache repository — a cache escape. The `.git`-symlink guard must
241
+ // reject it before the working copy is considered cloned.
242
+ const foreignGit = join(root, "foreign-git");
243
+ await mkdir(foreignGit, { recursive: true });
244
+ const localPath = join(cacheRoot, identity.slug);
245
+ await mkdir(localPath, { recursive: true });
246
+ await symlink(foreignGit, join(localPath, ".git"));
247
+
248
+ const resolver = new ContextResolver({ cacheRoot });
249
+ await assert.rejects(resolver.resolve(binding), /\.git is a symlink and would escape the cache root/);
250
+ } finally {
251
+ await rm(root, { recursive: true, force: true });
252
+ }
253
+ });
254
+
255
+ test("git backend rejects a real directory whose .git is a file (worktree/submodule marker) pointing outside the cache root", async () => {
256
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitfile-"));
257
+ try {
258
+ const cacheRoot = join(root, "cache");
259
+ const binding = { repo: join(root, "origin"), ref: "main" };
260
+ const identity = resolveContextIdentity(binding);
261
+ // `localPath` is a genuine directory (passing the symlink-at-localPath and
262
+ // `.git`-symlink guards), but its `.git` is a *file* — a git worktree /
263
+ // submodule `gitdir:` marker whose target points at a foreign gitdir OUTSIDE
264
+ // the cache root. `.git` merely *existing* must not be trusted as "already
265
+ // cloned": doing so would run fetch/pin against that out-of-cache
266
+ // repository, a cache escape. The backend must parse the `gitdir:` target
267
+ // and reject it because it escapes the cache root.
268
+ const foreignGit = join(root, "foreign-git");
269
+ await mkdir(foreignGit, { recursive: true });
270
+ const localPath = join(cacheRoot, identity.slug);
271
+ await mkdir(localPath, { recursive: true });
272
+ await writeFile(join(localPath, ".git"), `gitdir: ${foreignGit}\n`);
273
+
274
+ const resolver = new ContextResolver({ cacheRoot });
275
+ await assert.rejects(
276
+ resolver.resolve(binding),
277
+ /\.git points outside the cache root and would escape it/,
278
+ );
279
+ } finally {
280
+ await rm(root, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ test("git backend rejects a `.git` file whose in-cache gitdir is a symlink to an out-of-cache repository", async () => {
285
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitfile-symlink-"));
286
+ try {
287
+ const cacheRoot = join(root, "cache");
288
+ const binding = { repo: join(root, "origin"), ref: "main" };
289
+ const identity = resolveContextIdentity(binding);
290
+ // Defeat a string-only containment check: the `gitdir:` path lands INSIDE the
291
+ // cache root, but that in-cache path is itself a *symlink* to a foreign gitdir
292
+ // OUTSIDE the cache root. git would follow the link and operate on the
293
+ // out-of-cache repository — a cache escape. The marker must be canonicalised
294
+ // (realpath) so the symlink is collapsed and the escape is rejected.
295
+ const foreignGit = join(root, "foreign-git");
296
+ await mkdir(foreignGit, { recursive: true });
297
+ const localPath = join(cacheRoot, identity.slug);
298
+ await mkdir(localPath, { recursive: true });
299
+ const inCacheLink = join(cacheRoot, "linked-gitdir");
300
+ await symlink(foreignGit, inCacheLink);
301
+ await writeFile(join(localPath, ".git"), `gitdir: ${inCacheLink}\n`);
302
+
303
+ const resolver = new ContextResolver({ cacheRoot });
304
+ await assert.rejects(
305
+ resolver.resolve(binding),
306
+ /\.git points outside the cache root and would escape it/,
307
+ );
308
+ } finally {
309
+ await rm(root, { recursive: true, force: true });
310
+ }
311
+ });
312
+
313
+ test("git backend rejects a `.git` file whose gitdir target is missing/broken (not trusted as cloned)", async () => {
314
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitfile-broken-"));
315
+ try {
316
+ const cacheRoot = join(root, "cache");
317
+ const binding = { repo: join(root, "origin"), ref: "main" };
318
+ const identity = resolveContextIdentity(binding);
319
+ // The `gitdir:` path is textually inside the cache root but does not exist. A
320
+ // string-only check would trust it as "already cloned"; the realpath probe
321
+ // must treat a missing/broken gitdir as not-a-clone and reject it.
322
+ const localPath = join(cacheRoot, identity.slug);
323
+ await mkdir(localPath, { recursive: true });
324
+ await writeFile(join(localPath, ".git"), `gitdir: ${join(cacheRoot, "does-not-exist")}\n`);
325
+
326
+ const resolver = new ContextResolver({ cacheRoot });
327
+ await assert.rejects(
328
+ resolver.resolve(binding),
329
+ /gitdir target is missing or broken/,
330
+ );
331
+ } finally {
332
+ await rm(root, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+ test("git backend rejects a `.git` marker file with no `gitdir:` line as malformed (distinct message)", async () => {
337
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitfile-malformed-"));
338
+ try {
339
+ const cacheRoot = join(root, "cache");
340
+ const binding = { repo: join(root, "origin"), ref: "main" };
341
+ const identity = resolveContextIdentity(binding);
342
+ // A `.git` *file* that exists but carries no `gitdir:` line is malformed, not
343
+ // an escape. The rejection message must name that reason rather than the
344
+ // misleading "points outside the cache root" used for a genuine escape.
345
+ const localPath = join(cacheRoot, identity.slug);
346
+ await mkdir(localPath, { recursive: true });
347
+ await writeFile(join(localPath, ".git"), "this is not a gitdir marker\n");
348
+
349
+ const resolver = new ContextResolver({ cacheRoot });
350
+ await assert.rejects(
351
+ resolver.resolve(binding),
352
+ /\.git marker is malformed \(no `gitdir:` line\)/,
353
+ );
354
+ } finally {
355
+ await rm(root, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ test("git backend rejects a `.git` marker whose in-cache gitdir target is a file, not a directory", async () => {
360
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitfile-notdir-"));
361
+ try {
362
+ const cacheRoot = join(root, "cache");
363
+ const binding = { repo: join(root, "origin"), ref: "main" };
364
+ const identity = resolveContextIdentity(binding);
365
+ // The `gitdir:` target is inside the cache root and exists, but it is a plain
366
+ // file rather than a real gitdir directory. It stays in the cache root (not an
367
+ // escape), so the rejection must name "not a directory", not "points outside".
368
+ const localPath = join(cacheRoot, identity.slug);
369
+ await mkdir(localPath, { recursive: true });
370
+ const notAGitdir = join(cacheRoot, "not-a-gitdir");
371
+ await writeFile(notAGitdir, "i am a file\n");
372
+ await writeFile(join(localPath, ".git"), `gitdir: ${notAGitdir}\n`);
373
+
374
+ const resolver = new ContextResolver({ cacheRoot });
375
+ await assert.rejects(
376
+ resolver.resolve(binding),
377
+ /gitdir target is not a directory/,
378
+ );
379
+ } finally {
380
+ await rm(root, { recursive: true, force: true });
381
+ }
382
+ });
383
+
384
+ test("git backend rejects a relative localPath before running any git command", async () => {
385
+ const calls: string[][] = [];
386
+ const runner: GitRunner = async (args) => {
387
+ calls.push([...args]);
388
+ return "";
389
+ };
390
+ const backend = new GitSubstrateBackend(runner);
391
+ const identity = resolveContextIdentity({ repo: "/tmp/origin", ref: "main" });
392
+ // A direct backend caller that passes a relative `localPath` would otherwise run
393
+ // clone/fetch/checkout relative to the process CWD, sidestepping the cache-root
394
+ // containment guards. It must be rejected up front, before any git runs.
395
+ await assert.rejects(
396
+ backend.materialise(identity, { localPath: "relative/cache/copy", refresh: true }),
397
+ /localPath must be absolute/,
398
+ );
399
+ assert.equal(calls.length, 0, "no git command runs for a relative localPath");
400
+ });
401
+
402
+ test("git backend surfaces a non-ENOENT lstat error (unreadable path) instead of treating it as missing", async () => {
403
+ if (typeof process.getuid === "function" && process.getuid() === 0) {
404
+ return; // root bypasses permission bits, so EACCES can't be provoked
405
+ }
406
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-eacces-"));
407
+ try {
408
+ const cacheRoot = join(root, "cache");
409
+ const binding = { repo: join(root, "origin"), ref: "main" };
410
+ const identity = resolveContextIdentity(binding);
411
+ // Make the substrate dir unsearchable so `lstat(localPath/.git)` fails with
412
+ // EACCES (not ENOENT). An existing-but-unreadable path must NOT be reported
413
+ // as "missing" (which would proceed to clone) — it must surface the error.
414
+ const localPath = join(cacheRoot, identity.slug);
415
+ await mkdir(localPath, { recursive: true });
416
+ await chmod(localPath, 0o000);
417
+
418
+ const resolver = new ContextResolver({ cacheRoot });
419
+ await assert.rejects(resolver.resolve(binding), /not accessible/);
420
+ } finally {
421
+ await chmod(join(root, "cache", resolveContextIdentity({ repo: join(root, "origin"), ref: "main" }).slug), 0o755).catch(() => {});
422
+ await rm(root, { recursive: true, force: true });
423
+ }
424
+ });
425
+
426
+ test("git backend passes --end-of-options to rev-parse so an option-like ref can't be a git flag", async () => {
427
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-refguard-"));
428
+ try {
429
+ const calls: string[][] = [];
430
+ const runner: GitRunner = async (args) => {
431
+ calls.push([...args]);
432
+ return ""; // rev-parse "succeeds" ⇒ ref is treated as a remote branch
433
+ };
434
+ const backend = new GitSubstrateBackend(runner);
435
+ const identity = resolveContextIdentity({ repo: join(root, "origin"), ref: "main" });
436
+ const localPath = join(root, "cache", identity.slug);
437
+ await backend.materialise(identity, { localPath, refresh: true });
438
+
439
+ const revParse = calls.find((a) => a[0] === "rev-parse");
440
+ assert.ok(revParse, "rev-parse was invoked during pinning");
441
+ assert.ok(
442
+ revParse.includes("--end-of-options"),
443
+ "rev-parse stops option parsing before the manifest-controlled ref",
444
+ );
445
+ } finally {
446
+ await rm(root, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test("git backend re-points origin at the manifest repo before each refresh fetch", async () => {
451
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-origin-"));
452
+ try {
453
+ const origin = join(root, "origin");
454
+ const cacheRoot = join(root, "cache");
455
+ await execFileAsync("git", ["init", "-b", "main", origin]);
456
+ await git(origin, "config", "user.email", "t@example.com");
457
+ await git(origin, "config", "user.name", "Test");
458
+ await writeFile(join(origin, "a.txt"), "x\n");
459
+ await git(origin, "add", "a.txt");
460
+ await git(origin, "commit", "-m", "c1");
461
+
462
+ const binding = { repo: origin, ref: "main" };
463
+ const resolver = new ContextResolver({ cacheRoot });
464
+ const handle = await resolver.resolve(binding);
465
+
466
+ // Tamper with the cached clone's `origin`, the way a poisoned cache entry
467
+ // (or a stale entry left by an equivalent repo spelling with different auth)
468
+ // would: point it at an attacker-chosen remote that must never be fetched.
469
+ await git(handle.localPath, "remote", "set-url", "origin", join(root, "evil"));
470
+
471
+ // Refresh: the backend must reset `origin` back to the manifest repo, so the
472
+ // fetch operates on the expected remote rather than the tampered one.
473
+ await resolver.resolve(binding, { refresh: true });
474
+ assert.equal(
475
+ await git(handle.localPath, "remote", "get-url", "origin"),
476
+ origin,
477
+ "refresh re-points origin at the manifest repo before fetching",
478
+ );
479
+ } finally {
480
+ await rm(root, { recursive: true, force: true });
481
+ }
482
+ });
483
+
484
+ test("git backend sets origin from the manifest repo before fetch (set-url precedes fetch)", async () => {
485
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-originorder-"));
486
+ try {
487
+ const calls: string[][] = [];
488
+ const runner: GitRunner = async (args) => {
489
+ calls.push([...args]);
490
+ return "";
491
+ };
492
+ const backend = new GitSubstrateBackend(runner);
493
+ const identity = resolveContextIdentity({ repo: join(root, "origin"), ref: "main" });
494
+ // Pre-create a `.git` directory so materialise treats the path as cloned and
495
+ // takes the fetch/refresh branch rather than cloning.
496
+ const localPath = join(root, "cache", identity.slug);
497
+ await mkdir(join(localPath, ".git"), { recursive: true });
498
+ await backend.materialise(identity, { localPath, refresh: true });
499
+
500
+ const setUrlIdx = calls.findIndex((a) => a[0] === "remote" && a[1] === "set-url");
501
+ const fetchIdx = calls.findIndex((a) => a[0] === "fetch");
502
+ assert.ok(setUrlIdx >= 0, "origin URL is reset from the manifest repo on refresh");
503
+ assert.deepEqual(
504
+ calls[setUrlIdx],
505
+ ["remote", "set-url", "origin", "--", identity.repo],
506
+ "origin is re-pointed at the manifest repo with a `--` operand guard",
507
+ );
508
+ assert.ok(fetchIdx >= 0, "refresh fetches from origin");
509
+ assert.ok(setUrlIdx < fetchIdx, "origin is reset BEFORE the fetch runs");
510
+ } finally {
511
+ await rm(root, { recursive: true, force: true });
512
+ }
513
+ });
514
+
515
+ test("defaultGitRunner redaction scrubs credentialed URLs from error labels", () => {
516
+ assert.equal(
517
+ redactUrlUserinfo("https://x-access-token:SECRET@github.com/o/r.git"),
518
+ "https://***@github.com/o/r.git",
519
+ );
520
+ assert.equal(
521
+ redactUrlUserinfo("ssh://user:pw@host/o/r"),
522
+ "ssh://***@host/o/r",
523
+ );
524
+ // A URL without userinfo, and scp-style `git@host:path` (no `//`), are untouched.
525
+ assert.equal(redactUrlUserinfo("https://github.com/o/r.git"), "https://github.com/o/r.git");
526
+ assert.equal(redactUrlUserinfo("git@github.com:o/r.git"), "git@github.com:o/r.git");
527
+ });
528
+
529
+ async function git(cwd: string, ...args: string[]): Promise<string> {
530
+ const { stdout } = await execFileAsync("git", args, { cwd });
531
+ return stdout.trim();
532
+ }
533
+
534
+ async function pathThere(path: string): Promise<boolean> {
535
+ try {
536
+ await readFile(path);
537
+ return true;
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
542
+
543
+ test("hardenedGitArgs prepends core.hooksPath=/dev/null before the subcommand to disable hooks", () => {
544
+ const hardened = hardenedGitArgs(["fetch", "--tags", "origin"]);
545
+ // The `-c name=value` pair must precede the subcommand or git ignores it.
546
+ assert.deepEqual(hardened.slice(0, 3), ["-c", "core.hooksPath=/dev/null", "fetch"]);
547
+ assert.deepEqual(hardened, ["-c", "core.hooksPath=/dev/null", "fetch", "--tags", "origin"]);
548
+ // Pure/non-mutating: the original argv is untouched.
549
+ const original = ["checkout", "--force", "main"];
550
+ hardenedGitArgs(original);
551
+ assert.deepEqual(original, ["checkout", "--force", "main"]);
552
+ });
553
+
554
+ test("git backend does NOT execute a planted .git/hooks script when refreshing a poisoned cache entry", async () => {
555
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-hooks-"));
556
+ try {
557
+ const origin = join(root, "origin");
558
+ const cacheRoot = join(root, "cache");
559
+ await execFileAsync("git", ["init", "-b", "main", origin]);
560
+ await git(origin, "config", "user.email", "t@example.com");
561
+ await git(origin, "config", "user.name", "Test");
562
+ await writeFile(join(origin, "note.md"), "v1\n");
563
+ await git(origin, "add", "note.md");
564
+ await git(origin, "commit", "-m", "v1");
565
+
566
+ const binding = { repo: origin, ref: "main" };
567
+ const resolver = new ContextResolver({ cacheRoot });
568
+ const handle = await resolver.resolve(binding);
569
+
570
+ // Poison the cached working copy the way a cache-tampering attacker would:
571
+ // plant an executable hook that fires on the next fetch/checkout. If hooks
572
+ // were live, refreshing would run it and drop the sentinel file.
573
+ const sentinel = join(root, "pwned");
574
+ const hookDir = join(handle.localPath, ".git", "hooks");
575
+ await mkdir(hookDir, { recursive: true });
576
+ for (const name of ["post-checkout", "post-merge", "reference-transaction"]) {
577
+ const hook = join(hookDir, name);
578
+ await writeFile(hook, `#!/bin/sh\ntouch "${sentinel}"\n`);
579
+ await chmod(hook, 0o755);
580
+ }
581
+
582
+ // Advance origin and refresh — this runs fetch + checkout against the cache.
583
+ await writeFile(join(origin, "note.md"), "v2\n");
584
+ await git(origin, "commit", "-am", "v2");
585
+ await resolver.resolve(binding, { refresh: true });
586
+
587
+ assert.equal(
588
+ await pathThere(sentinel),
589
+ false,
590
+ "hooks under the cached .git must NOT execute during resolution",
591
+ );
592
+ } finally {
593
+ await rm(root, { recursive: true, force: true });
594
+ }
595
+ });
596
+
597
+ test("git backend surfaces an unreadable .git marker file as a SubstrateResolveError, not a raw Node error", async () => {
598
+ if (typeof process.getuid === "function" && process.getuid() === 0) {
599
+ return; // root bypasses permission bits, so EACCES can't be provoked
600
+ }
601
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-gitmarker-"));
602
+ const identity = resolveContextIdentity({ repo: join(root, "origin"), ref: "main" });
603
+ const gitFile = join(root, "cache", identity.slug, ".git");
604
+ try {
605
+ const cacheRoot = join(root, "cache");
606
+ const binding = { repo: join(root, "origin"), ref: "main" };
607
+ const localPath = join(cacheRoot, identity.slug);
608
+ await mkdir(localPath, { recursive: true });
609
+ // A `.git` *file* (worktree/submodule marker, not a directory) that exists
610
+ // but is unreadable must surface a wrapped SubstrateResolveError rather than
611
+ // leaking a raw EACCES from readFile.
612
+ await writeFile(gitFile, "gitdir: /somewhere\n");
613
+ await chmod(gitFile, 0o000);
614
+
615
+ const resolver = new ContextResolver({ cacheRoot });
616
+ await assert.rejects(resolver.resolve(binding, { refresh: false }), /marker is not readable/);
617
+ } finally {
618
+ await chmod(gitFile, 0o644).catch(() => {});
619
+ await rm(root, { recursive: true, force: true });
620
+ }
621
+ });
622
+
623
+
624
+
625
+ test("git backend clones on first use and re-pins on refresh; cross-instance sharing", async () => {
626
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-"));
627
+ try {
628
+ const origin = join(root, "origin");
629
+ const cacheRoot = join(root, "cache");
630
+ await execFileAsync("git", ["init", "-b", "main", origin]);
631
+ await git(origin, "config", "user.email", "t@example.com");
632
+ await git(origin, "config", "user.name", "Test");
633
+ await writeFile(join(origin, "note.md"), "v1\n");
634
+ await git(origin, "add", "note.md");
635
+ await git(origin, "commit", "-m", "v1");
636
+
637
+ const binding = { repo: origin, ref: "main" };
638
+ const resolver = new ContextResolver({ cacheRoot });
639
+ const handle = await resolver.resolve(binding);
640
+
641
+ assert.equal(handle.ref, "main");
642
+ assert.equal(handle.localPath, join(cacheRoot, handle.identity.slug));
643
+ assert.equal((await readFile(join(handle.localPath, "note.md"), "utf8")).trim(), "v1");
644
+
645
+ // Advance origin, then refresh the pinned working copy.
646
+ await writeFile(join(origin, "note.md"), "v2\n");
647
+ await git(origin, "commit", "-am", "v2");
648
+ const refreshed = await resolver.resolve(binding, { refresh: true });
649
+ assert.equal(
650
+ (await readFile(join(refreshed.localPath, "note.md"), "utf8")).trim(),
651
+ "v2",
652
+ );
653
+
654
+ // Cross-instance: a second resolver on the same cacheRoot reuses the same
655
+ // on-disk substrate (shared-on-same-name across processes/instances).
656
+ const other = new ContextResolver({ cacheRoot });
657
+ const shared = await other.resolve(binding, { refresh: false });
658
+ assert.equal(shared.localPath, handle.localPath);
659
+ } finally {
660
+ await rm(root, { recursive: true, force: true });
661
+ }
662
+ });
663
+
664
+ test("git backend treats a `.git` file (worktree/submodule marker) whose gitdir stays in the cache root as already cloned", async () => {
665
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-worktree-"));
666
+ try {
667
+ const cacheRoot = join(root, "cache");
668
+ const binding = { repo: join(root, "origin"), ref: "main" };
669
+ const identity = resolveContextIdentity(binding);
670
+ // A worktree/submodule marks its working copy with a `.git` *file* pointing
671
+ // at the real gitdir, not a `.git` *directory*. The backend must recognise
672
+ // it as a valid clone rather than treating it as an un-cloned/non-git path —
673
+ // BUT only when the gitdir target stays within the cache root (an
674
+ // out-of-cache gitdir is a cache escape, guarded by the next test).
675
+ const localPath = join(cacheRoot, identity.slug);
676
+ await mkdir(localPath, { recursive: true });
677
+ const inCacheGitdir = join(cacheRoot, ".git", "worktrees", "x");
678
+ await mkdir(inCacheGitdir, { recursive: true });
679
+ await writeFile(join(localPath, ".git"), `gitdir: ${inCacheGitdir}\n`);
680
+
681
+ const resolver = new ContextResolver({ cacheRoot });
682
+ // refresh:false so no git runs; the point is it must NOT throw "not a git
683
+ // working copy" — the in-cache `.git` file already proves it is a working copy.
684
+ const handle = await resolver.resolve(binding, { refresh: false });
685
+ assert.equal(handle.localPath, localPath);
686
+ } finally {
687
+ await rm(root, { recursive: true, force: true });
688
+ }
689
+ });
690
+
691
+ test("git backend pins to a tag (detached), not just branches", async () => {
692
+ const root = await mkdtemp(join(tmpdir(), "urban-ctx-tag-"));
693
+ try {
694
+ const origin = join(root, "origin");
695
+ const cacheRoot = join(root, "cache");
696
+ await execFileAsync("git", ["init", "-b", "main", origin]);
697
+ await git(origin, "config", "user.email", "t@example.com");
698
+ await git(origin, "config", "user.name", "Test");
699
+ await writeFile(join(origin, "a.txt"), "tagged\n");
700
+ await git(origin, "add", "a.txt");
701
+ await git(origin, "commit", "-m", "c1");
702
+ await git(origin, "tag", "v1.0.0");
703
+ // Move main forward so the tag is genuinely an older pin.
704
+ await writeFile(join(origin, "a.txt"), "moved\n");
705
+ await git(origin, "commit", "-am", "c2");
706
+
707
+ const resolver = new ContextResolver({ cacheRoot });
708
+ const handle = await resolver.resolve({ repo: origin, ref: "v1.0.0" });
709
+ assert.equal((await readFile(join(handle.localPath, "a.txt"), "utf8")).trim(), "tagged");
710
+ } finally {
711
+ await rm(root, { recursive: true, force: true });
712
+ }
713
+ });