@nanobpm/urban 0.64.0 → 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,368 @@
1
+ // Slice S1 (binding) — the default git substrate backend: clone on first use,
2
+ // fetch + re-pin to `ref` thereafter. No write/commit/PR logic lives here (that
3
+ // is S3); this only materialises a working copy pinned to the requested ref.
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import { promisify } from "node:util";
9
+ import type { ContextIdentity } from "./identity.ts";
10
+ import type {
11
+ ResolvedContextHandle,
12
+ SubstrateBackend,
13
+ SubstrateResolveOptions,
14
+ } from "./backend.ts";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ /** Runs a git subcommand. Injectable so tests can stub git without a network. */
19
+ export type GitRunner = (args: readonly string[], cwd?: string) => Promise<string>;
20
+
21
+ /** Error thrown when a git operation fails during resolution. */
22
+ export class SubstrateResolveError extends Error {
23
+ constructor(message: string, options?: { cause?: unknown }) {
24
+ super(message, options);
25
+ this.name = "SubstrateResolveError";
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Redact URL userinfo (`scheme://user:pass@host` → `scheme://***@host`) from a
31
+ * single git argv token. A manifest-supplied remote URL can embed credentials
32
+ * (e.g. `https://x-access-token:TOKEN@github.com/owner/repo`); scrubbing them
33
+ * before the argv is placed in an error message keeps secrets out of logs,
34
+ * telemetry, and user-facing errors. The raw, unredacted args stay on the
35
+ * error's `cause` for local debugging only.
36
+ */
37
+ export const redactUrlUserinfo = (arg: string): string =>
38
+ arg.replace(/(\/\/)[^/@\s]+@/g, "$1***@");
39
+
40
+ /**
41
+ * `git` runs against a working copy that lives in a shared, potentially
42
+ * tampered-with cache. Commands like `fetch` and especially `checkout` execute
43
+ * local hooks (e.g. `post-checkout`, `post-merge`) from the working copy's
44
+ * `.git/hooks` if they exist — so a planted hook in a poisoned cache entry is a
45
+ * code-execution vector. Pinning `core.hooksPath` to a path that cannot contain
46
+ * an executable hook (`/dev/null` is not a directory, so `<hooksPath>/<hook>`
47
+ * never resolves) disables hooks entirely for every automated invocation, so
48
+ * resolution can never execute a script from the cached substrate. Single
49
+ * source of truth for the flag: prepended by {@link hardenedGitArgs} to every
50
+ * argv the default runner spawns. `-c <name>=<value>` must precede the
51
+ * subcommand, so it is prepended (not appended).
52
+ */
53
+ export const GIT_SAFETY_CONFIG: readonly string[] = ["-c", "core.hooksPath=/dev/null"];
54
+
55
+ /** Prepend the hook-disabling safety config to a git argv (single source of truth). */
56
+ export const hardenedGitArgs = (args: readonly string[]): string[] => [
57
+ ...GIT_SAFETY_CONFIG,
58
+ ...args,
59
+ ];
60
+
61
+ const defaultGitRunner: GitRunner = async (args, cwd) => {
62
+ const hardened = hardenedGitArgs(args);
63
+ try {
64
+ const { stdout } = await execFileAsync("git", hardened, {
65
+ cwd,
66
+ maxBuffer: 64 * 1024 * 1024,
67
+ });
68
+ return stdout;
69
+ } catch (cause) {
70
+ const label = `git ${hardened.map(redactUrlUserinfo).join(" ")}`;
71
+ throw new SubstrateResolveError(`substrate git command failed: ${label}`, { cause });
72
+ }
73
+ };
74
+
75
+ /**
76
+ * True iff `path` exists as any filesystem entry. Uses `lstat` (not `stat`) so
77
+ * the entry itself is inspected without following symlinks — a symlink counts as
78
+ * a pre-existing entry even when its target is missing (a dangling symlink),
79
+ * which `git clone` would otherwise fail on with an opaque error.
80
+ *
81
+ * Only a genuine "not there" (`ENOENT`/`ENOTDIR`) counts as absent; any other
82
+ * error (e.g. `EACCES`/`EPERM` on an existing-but-unreadable entry) is
83
+ * re-thrown rather than silently reported as "missing", so an unreadable path
84
+ * is never mistaken for a clean slate to clone into.
85
+ */
86
+ async function pathExists(path: string): Promise<boolean> {
87
+ try {
88
+ await lstat(path);
89
+ return true;
90
+ } catch (cause) {
91
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
92
+ return false;
93
+ }
94
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
95
+ }
96
+ }
97
+
98
+ function isNodeErrno(error: unknown, code: string): boolean {
99
+ return (
100
+ error instanceof Error && "code" in error && error.code === code
101
+ );
102
+ }
103
+
104
+ /**
105
+ * True iff `path` exists and is itself a symlink (inspected with `lstat`, so the
106
+ * link — not its target — is examined). A missing entry (`ENOENT`/`ENOTDIR`) is
107
+ * not a symlink; any other error is re-thrown rather than silently swallowed.
108
+ */
109
+ async function isSymlink(path: string): Promise<boolean> {
110
+ try {
111
+ return (await lstat(path)).isSymbolicLink();
112
+ } catch (cause) {
113
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
114
+ return false;
115
+ }
116
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * True iff `path` exists and is a real directory (inspected with `lstat`, so a
122
+ * symlink to a directory does not count). A missing entry (`ENOENT`/`ENOTDIR`)
123
+ * is not a directory; any other error is re-thrown rather than silently
124
+ * swallowed, so an unreadable path is never mistaken for "not a directory".
125
+ */
126
+ async function isDirectory(path: string): Promise<boolean> {
127
+ try {
128
+ return (await lstat(path)).isDirectory();
129
+ } catch (cause) {
130
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
131
+ return false;
132
+ }
133
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
134
+ }
135
+ }
136
+
137
+ /** True iff `child` is `root` itself or nested within it (no `..`/absolute escape). */
138
+ function isWithin(root: string, child: string): boolean {
139
+ const rel = relative(root, child);
140
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
141
+ }
142
+
143
+ /**
144
+ * A `.git` *file* (git worktree/submodule marker) points at the real gitdir via
145
+ * a `gitdir: <path>` line rather than being a `.git` directory. Trusting such a
146
+ * working copy as "already cloned" is only safe when that gitdir stays inside
147
+ * the cache root: a marker whose `gitdir:` resolves OUTSIDE the cache root would
148
+ * make every subsequent `git fetch`/`checkout` operate on an out-of-cache
149
+ * repository — a cache escape of the same class as a `.git` symlink.
150
+ *
151
+ * A string-only containment check is insufficient: an in-cache `gitdir:` path
152
+ * can itself be a *symlink* to an out-of-cache repository (git follows it), and a
153
+ * broken/missing `gitdir:` target must not be trusted as a clone either. So the
154
+ * resolved target is canonicalised through `realpath` (collapsing every symlink
155
+ * along the path — including the gitdir itself) and the cache root is likewise
156
+ * canonicalised, before checking containment. Resolves when the marker is
157
+ * well-formed AND its fully-resolved gitdir is a real directory within the
158
+ * canonical `cacheRoot`. Otherwise it throws a `SubstrateResolveError` whose
159
+ * message names the *actual* reason — a malformed marker (no `gitdir:` line), a
160
+ * missing/broken target, one resolving to a non-directory, or one that genuinely
161
+ * escapes the cache root — rather than collapsing every rejection into a single
162
+ * "points outside the cache root" message that would mislead incident triage.
163
+ */
164
+ async function assertGitFileMarkerWithinRoot(
165
+ gitFile: string,
166
+ localPath: string,
167
+ cacheRoot: string,
168
+ ): Promise<void> {
169
+ // Read the marker file through the same error-wrapping discipline as the rest
170
+ // of this module's access guards: a filesystem failure (e.g. EACCES/EPERM on
171
+ // an unreadable marker, or a race that removes it) surfaces as a
172
+ // SubstrateResolveError rather than leaking a raw Node error to the caller.
173
+ let content: string;
174
+ try {
175
+ content = await readFile(gitFile, "utf8");
176
+ } catch (cause) {
177
+ throw new SubstrateResolveError(`substrate .git marker is not readable: ${gitFile}`, { cause });
178
+ }
179
+ const match = content.match(/^gitdir:[ \t]*(.+?)[ \t]*$/m);
180
+ if (match === null) {
181
+ throw new SubstrateResolveError(
182
+ `substrate .git marker is malformed (no \`gitdir:\` line): ${gitFile}`,
183
+ );
184
+ }
185
+ // A relative `gitdir:` is interpreted by git relative to the working copy, so
186
+ // resolve it against `localPath` before checking cache-root containment.
187
+ const target = resolve(localPath, match[1]);
188
+ // Canonicalise both sides through `realpath` so any symlink along the path —
189
+ // notably an in-cache `gitdir:` symlinked to an out-of-cache repository that a
190
+ // string check would wrongly trust — is collapsed before the comparison. A
191
+ // missing/broken target (ENOENT/ENOTDIR) is not a real gitdir and is rejected.
192
+ let realTarget: string;
193
+ let realRoot: string;
194
+ try {
195
+ realTarget = await realpath(target);
196
+ realRoot = await realpath(cacheRoot);
197
+ } catch (cause) {
198
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
199
+ throw new SubstrateResolveError(
200
+ `substrate .git gitdir target is missing or broken: ${target}`,
201
+ { cause },
202
+ );
203
+ }
204
+ throw new SubstrateResolveError(`substrate .git target is not accessible: ${target}`, { cause });
205
+ }
206
+ if (!isWithin(realRoot, realTarget)) {
207
+ throw new SubstrateResolveError(
208
+ `substrate .git points outside the cache root and would escape it: ${gitFile}`,
209
+ );
210
+ }
211
+ if (!(await isDirectory(realTarget))) {
212
+ throw new SubstrateResolveError(
213
+ `substrate .git gitdir target is not a directory: ${realTarget}`,
214
+ );
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The default, git-only substrate backend. Clones the substrate on first use
220
+ * and, on subsequent resolutions, fetches and re-pins the working copy to the
221
+ * requested ref (branch tip, tag, or SHA) without clobbering unrelated state.
222
+ */
223
+ export class GitSubstrateBackend implements SubstrateBackend {
224
+ readonly #git: GitRunner;
225
+
226
+ constructor(gitRunner: GitRunner = defaultGitRunner) {
227
+ this.#git = gitRunner;
228
+ }
229
+
230
+ /**
231
+ * Materialise the working copy at `options.localPath`: clone on first use,
232
+ * fetch + re-pin thereafter. `refresh: false` reuses an already-present
233
+ * working copy as-is — no fetch, no re-pin, no git invocation — so the
234
+ * returned handle's `ref` records the intended pin rather than a re-validated
235
+ * checkout (see {@link SubstrateResolveOptions.refresh}).
236
+ *
237
+ * Known limitation (deferred past slice S1): materialisation is NOT guarded by
238
+ * an inter-process lock. The resolver's single-flight coalescing only
239
+ * serialises resolves *within one process*, so two Nano instances that share a
240
+ * `cacheRoot` and resolve the same context concurrently can race the
241
+ * first-use `mkdir → clone → pin` (or `fetch → pin`) sequence for the same
242
+ * `localPath` — one process may observe the path as absent or
243
+ * partially-initialised while the other is still cloning, surfacing a spurious
244
+ * "not a git working copy" error or (worse) a half-written working copy.
245
+ * Cross-process sharing is therefore reliable only once the substrate is
246
+ * already materialised; the concurrent *first-use* case is a documented gap. A
247
+ * follow-up slice will add a per-`localPath` inter-process lock (bounded retry
248
+ * + stale-lock reclamation) to close it. See PR #309 / nano-ide binding notes.
249
+ */
250
+ async materialise(
251
+ identity: ContextIdentity,
252
+ options: SubstrateResolveOptions,
253
+ ): Promise<ResolvedContextHandle> {
254
+ const { localPath } = options;
255
+ const refresh = options.refresh ?? true;
256
+ // `SubstrateResolveOptions.localPath` is contractually absolute — the resolver
257
+ // derives it that way. Enforce it here too: a relative `localPath` from a
258
+ // direct backend caller would run clone/fetch/checkout relative to the process
259
+ // CWD, at an unpredictable location that also sidesteps the cache-root
260
+ // containment guards below. Reject it before any git command runs.
261
+ if (!isAbsolute(localPath)) {
262
+ throw new SubstrateResolveError(
263
+ `substrate localPath must be absolute: ${localPath}`,
264
+ );
265
+ }
266
+ // A symlink AT `localPath` would make every subsequent `join(localPath, …)`
267
+ // and git invocation operate on the link's target — which can point outside
268
+ // the cache root at an arbitrary pre-existing working copy. Reject it before
269
+ // it is ever trusted as a clone, so resolution can never escape the cache
270
+ // root via a planted symlink.
271
+ if (await isSymlink(localPath)) {
272
+ throw new SubstrateResolveError(
273
+ `substrate path is a symlink and would escape the cache root: ${localPath}`,
274
+ );
275
+ }
276
+ // Likewise, a `.git` symlink *inside* an otherwise-legitimate `localPath`
277
+ // would make git treat an arbitrary out-of-cache gitdir as this working
278
+ // copy's repository (cache escape). A real clone never has a symlinked
279
+ // `.git`, so reject one before it is trusted as "already cloned".
280
+ const gitDir = join(localPath, ".git");
281
+ if (await isSymlink(gitDir)) {
282
+ throw new SubstrateResolveError(
283
+ `substrate .git is a symlink and would escape the cache root: ${gitDir}`,
284
+ );
285
+ }
286
+ // A genuine clone's `.git` is a *directory*. A `.git` that exists as
287
+ // anything else is a git worktree/submodule marker *file* pointing at a real
288
+ // gitdir elsewhere, which must be validated before it is trusted — an
289
+ // out-of-cache gitdir would be a cache escape of the same class as the
290
+ // `.git`-symlink case above.
291
+ let alreadyCloned = await isDirectory(gitDir);
292
+ if (!alreadyCloned && (await pathExists(gitDir))) {
293
+ // `.git` exists but is not a directory: a git worktree/submodule marker
294
+ // *file* pointing at a real gitdir elsewhere. Trust it as "already cloned"
295
+ // only when that gitdir is well-formed and stays inside the cache root;
296
+ // otherwise this throws a precise error naming the actual reason (malformed
297
+ // marker, missing/broken target, non-directory, or genuine cache escape).
298
+ await assertGitFileMarkerWithinRoot(gitDir, localPath, dirname(localPath));
299
+ alreadyCloned = true;
300
+ }
301
+
302
+ if (!alreadyCloned) {
303
+ // A pre-existing entry at `localPath` that is not a git working copy — a
304
+ // non-git directory, or a file/symlink — would make `git clone` fail with
305
+ // an opaque wrapped error. Detect any such entry and report it clearly.
306
+ if (await pathExists(localPath)) {
307
+ throw new SubstrateResolveError(
308
+ `substrate path already exists but is not a git working copy: ${localPath}`,
309
+ );
310
+ }
311
+ await mkdir(dirname(localPath), { recursive: true });
312
+ // `--` separates options from operands so a `repo` starting with `-`
313
+ // (untrusted manifest input) can't be parsed as a git option.
314
+ await this.#git(["clone", "--", identity.repo, localPath]);
315
+ await this.#pin(localPath, identity.ref);
316
+ } else if (refresh) {
317
+ // Cache reuse must NOT trust the recorded `origin`: an equivalent repo
318
+ // spelling with different auth (e.g. SSH vs HTTPS/token) won't take effect
319
+ // once the entry exists, and a tampered cache entry could point `origin`
320
+ // at an attacker-chosen remote that the resolver would then fetch from.
321
+ // Re-point `origin` at the manifest's `identity.repo` before every fetch
322
+ // so refreshes always operate on the expected remote. `--` separates
323
+ // options from the operand so a `repo` starting with `-` (untrusted
324
+ // manifest input) can't be parsed as a git option.
325
+ await this.#git(["remote", "set-url", "origin", "--", identity.repo], localPath);
326
+ await this.#git(["fetch", "--tags", "--prune", "--force", "origin"], localPath);
327
+ await this.#pin(localPath, identity.ref);
328
+ }
329
+
330
+ // On the `alreadyCloned && !refresh` path neither branch above ran, so the
331
+ // existing working copy is returned untouched (no fetch/pin). `ref` here is
332
+ // therefore the *requested* pin, not a re-verified checkout — the documented
333
+ // `refresh: false` contract (trust the existing working copy as-is).
334
+ return { identity, localPath, repo: identity.repo, ref: identity.ref };
335
+ }
336
+
337
+ /**
338
+ * Pin the working copy to `ref`. If `ref` names a remote branch, pin to the
339
+ * fetched remote tip (so a refresh moves the branch forward); otherwise treat
340
+ * `ref` as a tag or SHA and check it out detached.
341
+ */
342
+ async #pin(cwd: string, ref: string): Promise<void> {
343
+ const remoteBranch = await this.#hasRef(cwd, `origin/${ref}`);
344
+ if (remoteBranch) {
345
+ await this.#git(["checkout", "--force", "-B", ref, `origin/${ref}`], cwd);
346
+ } else {
347
+ // `--detach` pins to the exact commit rather than attaching to any local
348
+ // branch that happens to share `ref`'s name; `--end-of-options` stops an
349
+ // option-style `ref` from being parsed as a git flag.
350
+ await this.#git(["checkout", "--force", "--detach", "--end-of-options", ref], cwd);
351
+ }
352
+ }
353
+
354
+ async #hasRef(cwd: string, ref: string): Promise<boolean> {
355
+ try {
356
+ // `--end-of-options` stops an option-style `ref` (manifest-controlled)
357
+ // from being parsed as a git flag — the same injection guard used by the
358
+ // `clone`/`checkout` paths.
359
+ await this.#git(
360
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`],
361
+ cwd,
362
+ );
363
+ return true;
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+ }
@@ -0,0 +1,165 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolve as resolvePath } from "node:path";
4
+ import {
5
+ contextIdentityKey,
6
+ resolveContextIdentity,
7
+ sameContext,
8
+ } from "./identity.ts";
9
+
10
+ test("same repo+ref ⇒ same identity key (shared-on-same-name)", () => {
11
+ const a = { repo: "owner/name", ref: "main" };
12
+ const b = { repo: "owner/name", ref: "main" };
13
+ assert.equal(contextIdentityKey(a), contextIdentityKey(b));
14
+ assert.equal(sameContext(a, b), true);
15
+ });
16
+
17
+ test("a different ref is a distinct, private context", () => {
18
+ const a = { repo: "owner/name", ref: "main" };
19
+ const b = { repo: "owner/name", ref: "release" };
20
+ assert.notEqual(contextIdentityKey(a), contextIdentityKey(b));
21
+ assert.equal(sameContext(a, b), false);
22
+ });
23
+
24
+ test("a different repo is a distinct, private context", () => {
25
+ const a = { repo: "owner/name", ref: "main" };
26
+ const b = { repo: "owner/other", ref: "main" };
27
+ assert.equal(sameContext(a, b), false);
28
+ });
29
+
30
+ test("shorthand and equivalent HTTPS URL normalise to the same identity", () => {
31
+ const shorthand = resolveContextIdentity({ repo: "Owner/Name", ref: "main" });
32
+ const url = resolveContextIdentity({
33
+ repo: "https://github.com/owner/name.git",
34
+ ref: "main",
35
+ });
36
+ assert.equal(shorthand.key, url.key);
37
+ });
38
+
39
+ test("a trailing slash after .git does not fork identity (canonical drift guard)", () => {
40
+ // `stripGitSuffix` must remove `.git` even when a trailing slash follows it,
41
+ // or `…/name.git/` canonicalises to a different substrate than `…/name`.
42
+ const plain = resolveContextIdentity({ repo: "https://github.com/owner/name", ref: "main" });
43
+ const dotGitSlash = resolveContextIdentity({
44
+ repo: "https://github.com/owner/name.git/",
45
+ ref: "main",
46
+ });
47
+ const scpSlash = resolveContextIdentity({ repo: "git@github.com:owner/name.git/", ref: "main" });
48
+ assert.equal(dotGitSlash.key, plain.key);
49
+ assert.equal(scpSlash.key, plain.key);
50
+ });
51
+
52
+ test("shorthand resolves to a concrete GitHub HTTPS clone URL", () => {
53
+ const identity = resolveContextIdentity({ repo: "owner/name", ref: "main" });
54
+ assert.equal(identity.repo, "https://github.com/owner/name.git");
55
+ });
56
+
57
+ test("scp-like and https URLs for the same repo share identity", () => {
58
+ const scp = resolveContextIdentity({ repo: "git@github.com:owner/name.git", ref: "v2" });
59
+ const https = resolveContextIdentity({ repo: "https://github.com/owner/name", ref: "v2" });
60
+ assert.equal(scp.key, https.key);
61
+ });
62
+
63
+ test("ssh://, scp-like and https URLs for one repo all share identity", () => {
64
+ const ssh = resolveContextIdentity({ repo: "ssh://git@github.com/owner/name.git", ref: "v2" });
65
+ const scp = resolveContextIdentity({ repo: "git@github.com:owner/name.git", ref: "v2" });
66
+ const https = resolveContextIdentity({ repo: "https://github.com/owner/name", ref: "v2" });
67
+ assert.equal(ssh.key, scp.key);
68
+ assert.equal(ssh.key, https.key);
69
+ });
70
+
71
+ test("embedded credentials and explicit default port do not affect identity", () => {
72
+ const plain = resolveContextIdentity({ repo: "https://github.com/owner/name.git", ref: "main" });
73
+ const creds = resolveContextIdentity({
74
+ repo: "https://user:token@github.com/owner/name.git",
75
+ ref: "main",
76
+ });
77
+ const port = resolveContextIdentity({
78
+ repo: "https://github.com:443/owner/name.git",
79
+ ref: "main",
80
+ });
81
+ assert.equal(creds.key, plain.key);
82
+ assert.equal(port.key, plain.key);
83
+ });
84
+
85
+ test("ssh:// explicit default port (22) does not affect identity", () => {
86
+ const plain = resolveContextIdentity({ repo: "ssh://git@github.com/owner/name.git", ref: "main" });
87
+ const port = resolveContextIdentity({
88
+ repo: "ssh://git@github.com:22/owner/name.git",
89
+ ref: "main",
90
+ });
91
+ assert.equal(port.key, plain.key);
92
+ });
93
+
94
+ test("non-default ports produce DISTINCT identities (no cross-repo collision)", () => {
95
+ const plain = resolveContextIdentity({ repo: "https://host.example/owner/name.git", ref: "main" });
96
+ const alt = resolveContextIdentity({
97
+ repo: "https://host.example:8443/owner/name.git",
98
+ ref: "main",
99
+ });
100
+ const other = resolveContextIdentity({
101
+ repo: "https://host.example:9443/owner/name.git",
102
+ ref: "main",
103
+ });
104
+ assert.notEqual(alt.key, plain.key);
105
+ assert.notEqual(alt.key, other.key);
106
+ // ssh non-default ports must distinguish too.
107
+ const ssh = resolveContextIdentity({ repo: "ssh://git@host.example/owner/name.git", ref: "main" });
108
+ const sshPort = resolveContextIdentity({
109
+ repo: "ssh://git@host.example:2222/owner/name.git",
110
+ ref: "main",
111
+ });
112
+ assert.notEqual(ssh.key, sshPort.key);
113
+ });
114
+
115
+ test("local file paths canonicalise by absolute path", () => {
116
+ const relative = resolveContextIdentity({ repo: "./sub/../sub/repo", ref: "main" });
117
+ const absolute = resolveContextIdentity({
118
+ repo: resolvePath("sub/repo"),
119
+ ref: "main",
120
+ });
121
+ const fileUrl = resolveContextIdentity({
122
+ repo: `file://${resolvePath("sub/repo")}`,
123
+ ref: "main",
124
+ });
125
+ assert.equal(relative.key, absolute.key);
126
+ assert.equal(absolute.key, fileUrl.key);
127
+ });
128
+
129
+ test("file:// host component and percent-encoding canonicalise to one identity", () => {
130
+ const abs = resolvePath("sub/repo");
131
+ const plain = resolveContextIdentity({ repo: `file://${abs}`, ref: "main" });
132
+ // `file://localhost/…` (explicit localhost host) is equivalent to `file:///…`.
133
+ const localhost = resolveContextIdentity({ repo: `file://localhost${abs}`, ref: "main" });
134
+ assert.equal(plain.key, localhost.key);
135
+ });
136
+
137
+ test("percent-encoded file:// path canonicalises to the decoded path identity", () => {
138
+ const dir = resolvePath("with space/repo");
139
+ const decoded = resolveContextIdentity({ repo: `file://${dir}`, ref: "main" });
140
+ const encoded = resolveContextIdentity({
141
+ repo: `file://${dir.replace(/ /g, "%20")}`,
142
+ ref: "main",
143
+ });
144
+ assert.equal(decoded.key, encoded.key);
145
+ });
146
+
147
+ test("slug is filesystem-safe and identity-stable", () => {
148
+ const a = resolveContextIdentity({ repo: "owner/name", ref: "feature/x" });
149
+ const b = resolveContextIdentity({ repo: "owner/name", ref: "feature/x" });
150
+ assert.equal(a.slug, b.slug);
151
+ assert.match(a.slug, /^[a-z0-9-]+$/);
152
+ });
153
+
154
+ test("slug suffix is the full identity key ⇒ distinct keys never share a localPath", () => {
155
+ // The on-disk working copy is `join(cacheRoot, slug)`, so the slug's uniqueness
156
+ // must be exactly as strong as the identity key — a truncated hash suffix would
157
+ // let two distinct keys collide onto one directory. Guard that the slug embeds
158
+ // the whole sha256 key (64 hex chars), not a truncated prefix.
159
+ const identity = resolveContextIdentity({ repo: "owner/name", ref: "main" });
160
+ assert.match(identity.key, /^[0-9a-f]{64}$/);
161
+ assert.ok(
162
+ identity.slug.endsWith(`-${identity.key}`),
163
+ "slug must be suffixed with the full identity key",
164
+ );
165
+ });