@nanobpm/urban 0.64.0 → 0.66.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.
Files changed (31) hide show
  1. package/dist/context/binding/backend.d.ts +58 -0
  2. package/dist/context/binding/backend.js +15 -0
  3. package/dist/context/binding/descriptor.d.ts +51 -0
  4. package/dist/context/binding/descriptor.js +62 -0
  5. package/dist/context/binding/git-backend.d.ts +65 -0
  6. package/dist/context/binding/git-backend.js +318 -0
  7. package/dist/context/binding/identity.d.ts +42 -0
  8. package/dist/context/binding/identity.js +159 -0
  9. package/dist/context/binding/index.d.ts +5 -1
  10. package/dist/context/binding/index.js +14 -1
  11. package/dist/context/binding/resolver.d.ts +61 -0
  12. package/dist/context/binding/resolver.js +125 -0
  13. package/dist/runtime/core/manifest.d.ts +24 -1
  14. package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
  15. package/dist/runtime/core/modules/instance-tracking.js +108 -11
  16. package/dist/runtime/core/validate.js +15 -0
  17. package/package.json +1 -1
  18. package/src/context/binding/backend.ts +79 -0
  19. package/src/context/binding/descriptor.test.ts +51 -0
  20. package/src/context/binding/descriptor.ts +108 -0
  21. package/src/context/binding/git-backend.ts +368 -0
  22. package/src/context/binding/identity.test.ts +165 -0
  23. package/src/context/binding/identity.ts +206 -0
  24. package/src/context/binding/index.ts +46 -2
  25. package/src/context/binding/resolver.test.ts +713 -0
  26. package/src/context/binding/resolver.ts +176 -0
  27. package/src/runtime/core/manifest.ts +22 -1
  28. package/src/runtime/core/modules/instance-tracking.test.ts +192 -6
  29. package/src/runtime/core/modules/instance-tracking.ts +115 -11
  30. package/src/runtime/core/validate.test.ts +46 -0
  31. package/src/runtime/core/validate.ts +15 -0
@@ -0,0 +1,79 @@
1
+ // Slice S1 (binding) — the resolution seam: types shared by the resolver and
2
+ // any substrate backend.
3
+ //
4
+ // Resolution turns a validated binding into a concrete local working copy of
5
+ // the substrate, returning a **resolved handle** (local path + repo/ref
6
+ // identity). Downstream slices consume the handle: S3 (git substrate /
7
+ // governance) writes append-via-commit into `localPath`, and S4 (retrieval)
8
+ // reads from it. S1 owns clone/pull wiring ONLY — it exposes a clean handle and
9
+ // deliberately contains no commit/PR/write or governance logic.
10
+ //
11
+ // The `SubstrateBackend` interface is the seam that keeps resolution from
12
+ // hard-coding "the substrate is exclusively a public git repo": the default
13
+ // backend is git, but a future PII/mutable or self-hosted backend can implement
14
+ // the same interface without touching the resolver or callers.
15
+
16
+ import type { ContextIdentity } from "./identity.ts";
17
+
18
+ /**
19
+ * A concrete, resolved handle to a context substrate: a local working copy
20
+ * pinned to `ref`, plus its repo/ref identity. This is the value S3 and S4
21
+ * build on.
22
+ */
23
+ export interface ResolvedContextHandle {
24
+ /** Canonical identity of the bound context (see {@link ContextIdentity}). */
25
+ readonly identity: ContextIdentity;
26
+ /** Absolute path to the local working copy of the substrate. */
27
+ readonly localPath: string;
28
+ /** The concrete substrate repo URL/path that was cloned. */
29
+ readonly repo: string;
30
+ /**
31
+ * The ref the working copy is pinned to. This records the *intended* pin
32
+ * (`identity.ref`). It is a re-verified fact only when the working copy was
33
+ * just cloned or refreshed; on the `refresh: false` reuse path the existing
34
+ * working copy is trusted as-is and NOT re-pinned, so `ref` reflects the
35
+ * requested pin rather than a freshly-validated checkout (see
36
+ * {@link SubstrateResolveOptions.refresh}).
37
+ */
38
+ readonly ref: string;
39
+ }
40
+
41
+ /** Options passed to a backend when materialising a substrate working copy. */
42
+ export interface SubstrateResolveOptions {
43
+ /**
44
+ * Absolute path the working copy MUST live at. The resolver derives this
45
+ * deterministically from the identity so that the same name maps to the same
46
+ * on-disk substrate (shared-on-same-name). A backend rejects a relative path
47
+ * (see {@link SubstrateBackend.materialise}).
48
+ */
49
+ readonly localPath: string;
50
+ /**
51
+ * When `true`, refresh an already-present working copy (fetch + re-pin to
52
+ * `ref`). When `false`, an existing working copy is reused as-is without
53
+ * touching the network AND without running git at all — it is trusted as the
54
+ * requested substrate; no fetch, pin, or checkout validation is enforced, so
55
+ * the returned handle's `ref` reflects the intended pin rather than a
56
+ * re-verified checkout. Defaults to `true`.
57
+ */
58
+ readonly refresh?: boolean;
59
+ }
60
+
61
+ /**
62
+ * A pluggable substrate backend. The default is git (see `./git-backend.ts`),
63
+ * but this interface is the seam for a future PII/mutable or self-hosted
64
+ * backend — the resolver and its callers depend only on this contract.
65
+ */
66
+ export interface SubstrateBackend {
67
+ /**
68
+ * Materialise (clone on first use, refresh thereafter) the substrate for
69
+ * `identity` at `options.localPath`, pinned to `identity.ref`, and return the
70
+ * resolved handle. The pin is enforced on the clone and `refresh: true` paths;
71
+ * `refresh: false` reuses an existing working copy as-is without re-pinning
72
+ * (see {@link SubstrateResolveOptions.refresh}). `options.localPath` must be
73
+ * absolute; a relative path is rejected before any git command runs.
74
+ */
75
+ materialise(
76
+ identity: ContextIdentity,
77
+ options: SubstrateResolveOptions,
78
+ ): Promise<ResolvedContextHandle>;
79
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ ContextBindingError,
5
+ isContextBinding,
6
+ parseContextBinding,
7
+ } from "./descriptor.ts";
8
+
9
+ test("parseContextBinding accepts a well-formed { repo, ref } and trims", () => {
10
+ const binding = parseContextBinding({ repo: " owner/name ", ref: " main " });
11
+ assert.deepEqual(binding, { repo: "owner/name", ref: "main" });
12
+ });
13
+
14
+ test("parseContextBinding ignores unknown extra fields (forward-compatible)", () => {
15
+ const binding = parseContextBinding({ repo: "owner/name", ref: "v1", future: 42 });
16
+ assert.deepEqual(binding, { repo: "owner/name", ref: "v1" });
17
+ });
18
+
19
+ test("parseContextBinding rejects non-objects with a clear message", () => {
20
+ assert.throws(() => parseContextBinding(null), ContextBindingError);
21
+ assert.throws(() => parseContextBinding("owner/name#main"), /must be an object/);
22
+ assert.throws(() => parseContextBinding(undefined), ContextBindingError);
23
+ });
24
+
25
+ test("parseContextBinding rejects arrays as not-an-object (typeof [] === 'object')", () => {
26
+ assert.throws(() => parseContextBinding([]), /must be an object/);
27
+ assert.throws(() => parseContextBinding(["owner/name", "main"]), /must be an object/);
28
+ assert.equal(isContextBinding([]), false);
29
+ });
30
+
31
+ test("parseContextBinding names arrays as 'array', not 'object', in the error message", () => {
32
+ assert.throws(() => parseContextBinding([]), /received array/);
33
+ assert.throws(() => parseContextBinding(["owner/name", "main"]), /received array/);
34
+ });
35
+
36
+ test("parseContextBinding rejects a missing or empty repo", () => {
37
+ assert.throws(() => parseContextBinding({ ref: "main" }), /"repo" is required/);
38
+ assert.throws(() => parseContextBinding({ repo: " ", ref: "main" }), /"repo" is required/);
39
+ assert.throws(() => parseContextBinding({ repo: 5, ref: "main" }), /"repo" is required/);
40
+ });
41
+
42
+ test("parseContextBinding rejects a missing or empty ref", () => {
43
+ assert.throws(() => parseContextBinding({ repo: "owner/name" }), /"ref" is required/);
44
+ assert.throws(() => parseContextBinding({ repo: "owner/name", ref: "" }), /"ref" is required/);
45
+ });
46
+
47
+ test("isContextBinding is a total type guard", () => {
48
+ assert.equal(isContextBinding({ repo: "owner/name", ref: "main" }), true);
49
+ assert.equal(isContextBinding({ repo: "owner/name" }), false);
50
+ assert.equal(isContextBinding(42), false);
51
+ });
@@ -0,0 +1,108 @@
1
+ // Slice S1 (binding) — the binding *descriptor*: the manifest shape
2
+ // `uses: context: { repo, ref }`.
3
+ //
4
+ // This is one half of the published contract that the consumer repo
5
+ // (nanobpm/nano-workforce#291) builds against, so the shape and the exported
6
+ // type name are deliberately **minimal and version-friendly**: a context is
7
+ // named by the substrate `repo` it lives in and the `ref` it is pinned to, and
8
+ // nothing more is required to bind it. Additional, backward-compatible fields
9
+ // may be layered on later without breaking existing manifests.
10
+
11
+ /**
12
+ * A declared binding to an Urban **context** resource — the manifest shape
13
+ * authored under `uses: { context: { repo, ref } }`.
14
+ *
15
+ * A context is a git-backed, governed memory substrate (see ADR 0051 — a
16
+ * context behaves like an Urban datasource: a declared, bindable resource). The
17
+ * pair `(repo, ref)` is the *name* of the context: two apps that declare the
18
+ * same pair bind the same shared substrate; a different pair is a different,
19
+ * private context (see {@link contextIdentityKey} in `./identity.ts`).
20
+ *
21
+ * The type is intentionally small and additive-friendly — treat it as a stable
22
+ * contract. Unknown extra fields on input are tolerated by the validator but
23
+ * are not part of the guaranteed shape.
24
+ */
25
+ export interface ContextBinding {
26
+ /**
27
+ * The substrate repository that stores the context.
28
+ *
29
+ * Accepts an `owner/name` GitHub shorthand (resolved to
30
+ * `https://github.com/owner/name.git`), a full git URL
31
+ * (`https://…`, `ssh://…`, `git@host:owner/name.git`), or a local/`file://`
32
+ * path (used chiefly for tests and self-hosted substrates). A local path must
33
+ * be spelled unambiguously so it is not mistaken for the `owner/name`
34
+ * shorthand: use an absolute path, a `file://…` URL, or a `./`- or
35
+ * `../`-prefixed relative path. A bare relative path such as `subdir/repo`
36
+ * matches the shorthand and is treated as GitHub, not a local path. The MVP
37
+ * substrate is git-only, but resolution does not hard-code *public* git — see
38
+ * the backend seam in `./resolver.ts`.
39
+ */
40
+ readonly repo: string;
41
+
42
+ /**
43
+ * The ref the context is **pinned** to: a branch, tag, or commit SHA. First
44
+ * resolution clones the substrate; later resolutions refresh it to this ref.
45
+ */
46
+ readonly ref: string;
47
+ }
48
+
49
+ /** Error thrown when an untrusted value is not a valid {@link ContextBinding}. */
50
+ export class ContextBindingError extends Error {
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "ContextBindingError";
54
+ }
55
+ }
56
+
57
+ function isNonEmptyString(value: unknown): value is string {
58
+ return typeof value === "string" && value.trim().length > 0;
59
+ }
60
+
61
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+
65
+ /**
66
+ * Validate an untrusted value as a {@link ContextBinding}, throwing a
67
+ * {@link ContextBindingError} with a clear, field-scoped message on failure.
68
+ *
69
+ * Returns a *normalised* binding: `repo` and `ref` are trimmed. Extra fields on
70
+ * the input object are ignored (forward-compatible) — only `repo` and `ref` are
71
+ * carried through.
72
+ */
73
+ export function parseContextBinding(value: unknown): ContextBinding {
74
+ if (!isObjectRecord(value)) {
75
+ throw new ContextBindingError(
76
+ `context binding must be an object of the form { repo, ref }, received ${describe(value)}`,
77
+ );
78
+ }
79
+ const record = value;
80
+ if (!("repo" in record) || !isNonEmptyString(record.repo)) {
81
+ throw new ContextBindingError(
82
+ `context binding "repo" is required and must be a non-empty string, received ${describe(record.repo)}`,
83
+ );
84
+ }
85
+ if (!("ref" in record) || !isNonEmptyString(record.ref)) {
86
+ throw new ContextBindingError(
87
+ `context binding "ref" is required and must be a non-empty string, received ${describe(record.ref)}`,
88
+ );
89
+ }
90
+ return { repo: record.repo.trim(), ref: record.ref.trim() };
91
+ }
92
+
93
+ /** Type guard: `true` iff `value` is a valid {@link ContextBinding}. */
94
+ export function isContextBinding(value: unknown): value is ContextBinding {
95
+ try {
96
+ parseContextBinding(value);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function describe(value: unknown): string {
104
+ if (typeof value === "string") return JSON.stringify(value);
105
+ if (value === null) return "null";
106
+ if (Array.isArray(value)) return "array";
107
+ return typeof value;
108
+ }
@@ -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
+ }