@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.
- package/dist/context/binding/backend.d.ts +58 -0
- package/dist/context/binding/backend.js +15 -0
- package/dist/context/binding/descriptor.d.ts +51 -0
- package/dist/context/binding/descriptor.js +62 -0
- package/dist/context/binding/git-backend.d.ts +65 -0
- package/dist/context/binding/git-backend.js +318 -0
- package/dist/context/binding/identity.d.ts +42 -0
- package/dist/context/binding/identity.js +159 -0
- package/dist/context/binding/index.d.ts +5 -1
- package/dist/context/binding/index.js +14 -1
- package/dist/context/binding/resolver.d.ts +61 -0
- package/dist/context/binding/resolver.js +125 -0
- package/package.json +1 -1
- package/src/context/binding/backend.ts +79 -0
- package/src/context/binding/descriptor.test.ts +51 -0
- package/src/context/binding/descriptor.ts +108 -0
- package/src/context/binding/git-backend.ts +368 -0
- package/src/context/binding/identity.test.ts +165 -0
- package/src/context/binding/identity.ts +206 -0
- package/src/context/binding/index.ts +46 -2
- package/src/context/binding/resolver.test.ts +713 -0
- package/src/context/binding/resolver.ts +176 -0
|
@@ -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
|
+
});
|