@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,58 @@
1
+ import type { ContextIdentity } from "./identity.ts";
2
+ /**
3
+ * A concrete, resolved handle to a context substrate: a local working copy
4
+ * pinned to `ref`, plus its repo/ref identity. This is the value S3 and S4
5
+ * build on.
6
+ */
7
+ export interface ResolvedContextHandle {
8
+ /** Canonical identity of the bound context (see {@link ContextIdentity}). */
9
+ readonly identity: ContextIdentity;
10
+ /** Absolute path to the local working copy of the substrate. */
11
+ readonly localPath: string;
12
+ /** The concrete substrate repo URL/path that was cloned. */
13
+ readonly repo: string;
14
+ /**
15
+ * The ref the working copy is pinned to. This records the *intended* pin
16
+ * (`identity.ref`). It is a re-verified fact only when the working copy was
17
+ * just cloned or refreshed; on the `refresh: false` reuse path the existing
18
+ * working copy is trusted as-is and NOT re-pinned, so `ref` reflects the
19
+ * requested pin rather than a freshly-validated checkout (see
20
+ * {@link SubstrateResolveOptions.refresh}).
21
+ */
22
+ readonly ref: string;
23
+ }
24
+ /** Options passed to a backend when materialising a substrate working copy. */
25
+ export interface SubstrateResolveOptions {
26
+ /**
27
+ * Absolute path the working copy MUST live at. The resolver derives this
28
+ * deterministically from the identity so that the same name maps to the same
29
+ * on-disk substrate (shared-on-same-name). A backend rejects a relative path
30
+ * (see {@link SubstrateBackend.materialise}).
31
+ */
32
+ readonly localPath: string;
33
+ /**
34
+ * When `true`, refresh an already-present working copy (fetch + re-pin to
35
+ * `ref`). When `false`, an existing working copy is reused as-is without
36
+ * touching the network AND without running git at all — it is trusted as the
37
+ * requested substrate; no fetch, pin, or checkout validation is enforced, so
38
+ * the returned handle's `ref` reflects the intended pin rather than a
39
+ * re-verified checkout. Defaults to `true`.
40
+ */
41
+ readonly refresh?: boolean;
42
+ }
43
+ /**
44
+ * A pluggable substrate backend. The default is git (see `./git-backend.ts`),
45
+ * but this interface is the seam for a future PII/mutable or self-hosted
46
+ * backend — the resolver and its callers depend only on this contract.
47
+ */
48
+ export interface SubstrateBackend {
49
+ /**
50
+ * Materialise (clone on first use, refresh thereafter) the substrate for
51
+ * `identity` at `options.localPath`, pinned to `identity.ref`, and return the
52
+ * resolved handle. The pin is enforced on the clone and `refresh: true` paths;
53
+ * `refresh: false` reuses an existing working copy as-is without re-pinning
54
+ * (see {@link SubstrateResolveOptions.refresh}). `options.localPath` must be
55
+ * absolute; a relative path is rejected before any git command runs.
56
+ */
57
+ materialise(identity: ContextIdentity, options: SubstrateResolveOptions): Promise<ResolvedContextHandle>;
58
+ }
@@ -0,0 +1,15 @@
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
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A declared binding to an Urban **context** resource — the manifest shape
3
+ * authored under `uses: { context: { repo, ref } }`.
4
+ *
5
+ * A context is a git-backed, governed memory substrate (see ADR 0051 — a
6
+ * context behaves like an Urban datasource: a declared, bindable resource). The
7
+ * pair `(repo, ref)` is the *name* of the context: two apps that declare the
8
+ * same pair bind the same shared substrate; a different pair is a different,
9
+ * private context (see {@link contextIdentityKey} in `./identity.ts`).
10
+ *
11
+ * The type is intentionally small and additive-friendly — treat it as a stable
12
+ * contract. Unknown extra fields on input are tolerated by the validator but
13
+ * are not part of the guaranteed shape.
14
+ */
15
+ export interface ContextBinding {
16
+ /**
17
+ * The substrate repository that stores the context.
18
+ *
19
+ * Accepts an `owner/name` GitHub shorthand (resolved to
20
+ * `https://github.com/owner/name.git`), a full git URL
21
+ * (`https://…`, `ssh://…`, `git@host:owner/name.git`), or a local/`file://`
22
+ * path (used chiefly for tests and self-hosted substrates). A local path must
23
+ * be spelled unambiguously so it is not mistaken for the `owner/name`
24
+ * shorthand: use an absolute path, a `file://…` URL, or a `./`- or
25
+ * `../`-prefixed relative path. A bare relative path such as `subdir/repo`
26
+ * matches the shorthand and is treated as GitHub, not a local path. The MVP
27
+ * substrate is git-only, but resolution does not hard-code *public* git — see
28
+ * the backend seam in `./resolver.ts`.
29
+ */
30
+ readonly repo: string;
31
+ /**
32
+ * The ref the context is **pinned** to: a branch, tag, or commit SHA. First
33
+ * resolution clones the substrate; later resolutions refresh it to this ref.
34
+ */
35
+ readonly ref: string;
36
+ }
37
+ /** Error thrown when an untrusted value is not a valid {@link ContextBinding}. */
38
+ export declare class ContextBindingError extends Error {
39
+ constructor(message: string);
40
+ }
41
+ /**
42
+ * Validate an untrusted value as a {@link ContextBinding}, throwing a
43
+ * {@link ContextBindingError} with a clear, field-scoped message on failure.
44
+ *
45
+ * Returns a *normalised* binding: `repo` and `ref` are trimmed. Extra fields on
46
+ * the input object are ignored (forward-compatible) — only `repo` and `ref` are
47
+ * carried through.
48
+ */
49
+ export declare function parseContextBinding(value: unknown): ContextBinding;
50
+ /** Type guard: `true` iff `value` is a valid {@link ContextBinding}. */
51
+ export declare function isContextBinding(value: unknown): value is ContextBinding;
@@ -0,0 +1,62 @@
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
+ /** Error thrown when an untrusted value is not a valid {@link ContextBinding}. */
11
+ export class ContextBindingError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "ContextBindingError";
15
+ }
16
+ }
17
+ function isNonEmptyString(value) {
18
+ return typeof value === "string" && value.trim().length > 0;
19
+ }
20
+ function isObjectRecord(value) {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+ /**
24
+ * Validate an untrusted value as a {@link ContextBinding}, throwing a
25
+ * {@link ContextBindingError} with a clear, field-scoped message on failure.
26
+ *
27
+ * Returns a *normalised* binding: `repo` and `ref` are trimmed. Extra fields on
28
+ * the input object are ignored (forward-compatible) — only `repo` and `ref` are
29
+ * carried through.
30
+ */
31
+ export function parseContextBinding(value) {
32
+ if (!isObjectRecord(value)) {
33
+ throw new ContextBindingError(`context binding must be an object of the form { repo, ref }, received ${describe(value)}`);
34
+ }
35
+ const record = value;
36
+ if (!("repo" in record) || !isNonEmptyString(record.repo)) {
37
+ throw new ContextBindingError(`context binding "repo" is required and must be a non-empty string, received ${describe(record.repo)}`);
38
+ }
39
+ if (!("ref" in record) || !isNonEmptyString(record.ref)) {
40
+ throw new ContextBindingError(`context binding "ref" is required and must be a non-empty string, received ${describe(record.ref)}`);
41
+ }
42
+ return { repo: record.repo.trim(), ref: record.ref.trim() };
43
+ }
44
+ /** Type guard: `true` iff `value` is a valid {@link ContextBinding}. */
45
+ export function isContextBinding(value) {
46
+ try {
47
+ parseContextBinding(value);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function describe(value) {
55
+ if (typeof value === "string")
56
+ return JSON.stringify(value);
57
+ if (value === null)
58
+ return "null";
59
+ if (Array.isArray(value))
60
+ return "array";
61
+ return typeof value;
62
+ }
@@ -0,0 +1,65 @@
1
+ import type { ContextIdentity } from "./identity.ts";
2
+ import type { ResolvedContextHandle, SubstrateBackend, SubstrateResolveOptions } from "./backend.ts";
3
+ /** Runs a git subcommand. Injectable so tests can stub git without a network. */
4
+ export type GitRunner = (args: readonly string[], cwd?: string) => Promise<string>;
5
+ /** Error thrown when a git operation fails during resolution. */
6
+ export declare class SubstrateResolveError extends Error {
7
+ constructor(message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ /**
12
+ * Redact URL userinfo (`scheme://user:pass@host` → `scheme://***@host`) from a
13
+ * single git argv token. A manifest-supplied remote URL can embed credentials
14
+ * (e.g. `https://x-access-token:TOKEN@github.com/owner/repo`); scrubbing them
15
+ * before the argv is placed in an error message keeps secrets out of logs,
16
+ * telemetry, and user-facing errors. The raw, unredacted args stay on the
17
+ * error's `cause` for local debugging only.
18
+ */
19
+ export declare const redactUrlUserinfo: (arg: string) => string;
20
+ /**
21
+ * `git` runs against a working copy that lives in a shared, potentially
22
+ * tampered-with cache. Commands like `fetch` and especially `checkout` execute
23
+ * local hooks (e.g. `post-checkout`, `post-merge`) from the working copy's
24
+ * `.git/hooks` if they exist — so a planted hook in a poisoned cache entry is a
25
+ * code-execution vector. Pinning `core.hooksPath` to a path that cannot contain
26
+ * an executable hook (`/dev/null` is not a directory, so `<hooksPath>/<hook>`
27
+ * never resolves) disables hooks entirely for every automated invocation, so
28
+ * resolution can never execute a script from the cached substrate. Single
29
+ * source of truth for the flag: prepended by {@link hardenedGitArgs} to every
30
+ * argv the default runner spawns. `-c <name>=<value>` must precede the
31
+ * subcommand, so it is prepended (not appended).
32
+ */
33
+ export declare const GIT_SAFETY_CONFIG: readonly string[];
34
+ /** Prepend the hook-disabling safety config to a git argv (single source of truth). */
35
+ export declare const hardenedGitArgs: (args: readonly string[]) => string[];
36
+ /**
37
+ * The default, git-only substrate backend. Clones the substrate on first use
38
+ * and, on subsequent resolutions, fetches and re-pins the working copy to the
39
+ * requested ref (branch tip, tag, or SHA) without clobbering unrelated state.
40
+ */
41
+ export declare class GitSubstrateBackend implements SubstrateBackend {
42
+ #private;
43
+ constructor(gitRunner?: GitRunner);
44
+ /**
45
+ * Materialise the working copy at `options.localPath`: clone on first use,
46
+ * fetch + re-pin thereafter. `refresh: false` reuses an already-present
47
+ * working copy as-is — no fetch, no re-pin, no git invocation — so the
48
+ * returned handle's `ref` records the intended pin rather than a re-validated
49
+ * checkout (see {@link SubstrateResolveOptions.refresh}).
50
+ *
51
+ * Known limitation (deferred past slice S1): materialisation is NOT guarded by
52
+ * an inter-process lock. The resolver's single-flight coalescing only
53
+ * serialises resolves *within one process*, so two Nano instances that share a
54
+ * `cacheRoot` and resolve the same context concurrently can race the
55
+ * first-use `mkdir → clone → pin` (or `fetch → pin`) sequence for the same
56
+ * `localPath` — one process may observe the path as absent or
57
+ * partially-initialised while the other is still cloning, surfacing a spurious
58
+ * "not a git working copy" error or (worse) a half-written working copy.
59
+ * Cross-process sharing is therefore reliable only once the substrate is
60
+ * already materialised; the concurrent *first-use* case is a documented gap. A
61
+ * follow-up slice will add a per-`localPath` inter-process lock (bounded retry
62
+ * + stale-lock reclamation) to close it. See PR #309 / nano-ide binding notes.
63
+ */
64
+ materialise(identity: ContextIdentity, options: SubstrateResolveOptions): Promise<ResolvedContextHandle>;
65
+ }
@@ -0,0 +1,318 @@
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
+ import { execFile } from "node:child_process";
5
+ import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ /** Error thrown when a git operation fails during resolution. */
10
+ export class SubstrateResolveError extends Error {
11
+ constructor(message, options) {
12
+ super(message, options);
13
+ this.name = "SubstrateResolveError";
14
+ }
15
+ }
16
+ /**
17
+ * Redact URL userinfo (`scheme://user:pass@host` → `scheme://***@host`) from a
18
+ * single git argv token. A manifest-supplied remote URL can embed credentials
19
+ * (e.g. `https://x-access-token:TOKEN@github.com/owner/repo`); scrubbing them
20
+ * before the argv is placed in an error message keeps secrets out of logs,
21
+ * telemetry, and user-facing errors. The raw, unredacted args stay on the
22
+ * error's `cause` for local debugging only.
23
+ */
24
+ export const redactUrlUserinfo = (arg) => arg.replace(/(\/\/)[^/@\s]+@/g, "$1***@");
25
+ /**
26
+ * `git` runs against a working copy that lives in a shared, potentially
27
+ * tampered-with cache. Commands like `fetch` and especially `checkout` execute
28
+ * local hooks (e.g. `post-checkout`, `post-merge`) from the working copy's
29
+ * `.git/hooks` if they exist — so a planted hook in a poisoned cache entry is a
30
+ * code-execution vector. Pinning `core.hooksPath` to a path that cannot contain
31
+ * an executable hook (`/dev/null` is not a directory, so `<hooksPath>/<hook>`
32
+ * never resolves) disables hooks entirely for every automated invocation, so
33
+ * resolution can never execute a script from the cached substrate. Single
34
+ * source of truth for the flag: prepended by {@link hardenedGitArgs} to every
35
+ * argv the default runner spawns. `-c <name>=<value>` must precede the
36
+ * subcommand, so it is prepended (not appended).
37
+ */
38
+ export const GIT_SAFETY_CONFIG = ["-c", "core.hooksPath=/dev/null"];
39
+ /** Prepend the hook-disabling safety config to a git argv (single source of truth). */
40
+ export const hardenedGitArgs = (args) => [
41
+ ...GIT_SAFETY_CONFIG,
42
+ ...args,
43
+ ];
44
+ const defaultGitRunner = async (args, cwd) => {
45
+ const hardened = hardenedGitArgs(args);
46
+ try {
47
+ const { stdout } = await execFileAsync("git", hardened, {
48
+ cwd,
49
+ maxBuffer: 64 * 1024 * 1024,
50
+ });
51
+ return stdout;
52
+ }
53
+ catch (cause) {
54
+ const label = `git ${hardened.map(redactUrlUserinfo).join(" ")}`;
55
+ throw new SubstrateResolveError(`substrate git command failed: ${label}`, { cause });
56
+ }
57
+ };
58
+ /**
59
+ * True iff `path` exists as any filesystem entry. Uses `lstat` (not `stat`) so
60
+ * the entry itself is inspected without following symlinks — a symlink counts as
61
+ * a pre-existing entry even when its target is missing (a dangling symlink),
62
+ * which `git clone` would otherwise fail on with an opaque error.
63
+ *
64
+ * Only a genuine "not there" (`ENOENT`/`ENOTDIR`) counts as absent; any other
65
+ * error (e.g. `EACCES`/`EPERM` on an existing-but-unreadable entry) is
66
+ * re-thrown rather than silently reported as "missing", so an unreadable path
67
+ * is never mistaken for a clean slate to clone into.
68
+ */
69
+ async function pathExists(path) {
70
+ try {
71
+ await lstat(path);
72
+ return true;
73
+ }
74
+ catch (cause) {
75
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
76
+ return false;
77
+ }
78
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
79
+ }
80
+ }
81
+ function isNodeErrno(error, code) {
82
+ return (error instanceof Error && "code" in error && error.code === code);
83
+ }
84
+ /**
85
+ * True iff `path` exists and is itself a symlink (inspected with `lstat`, so the
86
+ * link — not its target — is examined). A missing entry (`ENOENT`/`ENOTDIR`) is
87
+ * not a symlink; any other error is re-thrown rather than silently swallowed.
88
+ */
89
+ async function isSymlink(path) {
90
+ try {
91
+ return (await lstat(path)).isSymbolicLink();
92
+ }
93
+ catch (cause) {
94
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
95
+ return false;
96
+ }
97
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
98
+ }
99
+ }
100
+ /**
101
+ * True iff `path` exists and is a real directory (inspected with `lstat`, so a
102
+ * symlink to a directory does not count). A missing entry (`ENOENT`/`ENOTDIR`)
103
+ * is not a directory; any other error is re-thrown rather than silently
104
+ * swallowed, so an unreadable path is never mistaken for "not a directory".
105
+ */
106
+ async function isDirectory(path) {
107
+ try {
108
+ return (await lstat(path)).isDirectory();
109
+ }
110
+ catch (cause) {
111
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
112
+ return false;
113
+ }
114
+ throw new SubstrateResolveError(`substrate path is not accessible: ${path}`, { cause });
115
+ }
116
+ }
117
+ /** True iff `child` is `root` itself or nested within it (no `..`/absolute escape). */
118
+ function isWithin(root, child) {
119
+ const rel = relative(root, child);
120
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
121
+ }
122
+ /**
123
+ * A `.git` *file* (git worktree/submodule marker) points at the real gitdir via
124
+ * a `gitdir: <path>` line rather than being a `.git` directory. Trusting such a
125
+ * working copy as "already cloned" is only safe when that gitdir stays inside
126
+ * the cache root: a marker whose `gitdir:` resolves OUTSIDE the cache root would
127
+ * make every subsequent `git fetch`/`checkout` operate on an out-of-cache
128
+ * repository — a cache escape of the same class as a `.git` symlink.
129
+ *
130
+ * A string-only containment check is insufficient: an in-cache `gitdir:` path
131
+ * can itself be a *symlink* to an out-of-cache repository (git follows it), and a
132
+ * broken/missing `gitdir:` target must not be trusted as a clone either. So the
133
+ * resolved target is canonicalised through `realpath` (collapsing every symlink
134
+ * along the path — including the gitdir itself) and the cache root is likewise
135
+ * canonicalised, before checking containment. Resolves when the marker is
136
+ * well-formed AND its fully-resolved gitdir is a real directory within the
137
+ * canonical `cacheRoot`. Otherwise it throws a `SubstrateResolveError` whose
138
+ * message names the *actual* reason — a malformed marker (no `gitdir:` line), a
139
+ * missing/broken target, one resolving to a non-directory, or one that genuinely
140
+ * escapes the cache root — rather than collapsing every rejection into a single
141
+ * "points outside the cache root" message that would mislead incident triage.
142
+ */
143
+ async function assertGitFileMarkerWithinRoot(gitFile, localPath, cacheRoot) {
144
+ // Read the marker file through the same error-wrapping discipline as the rest
145
+ // of this module's access guards: a filesystem failure (e.g. EACCES/EPERM on
146
+ // an unreadable marker, or a race that removes it) surfaces as a
147
+ // SubstrateResolveError rather than leaking a raw Node error to the caller.
148
+ let content;
149
+ try {
150
+ content = await readFile(gitFile, "utf8");
151
+ }
152
+ catch (cause) {
153
+ throw new SubstrateResolveError(`substrate .git marker is not readable: ${gitFile}`, { cause });
154
+ }
155
+ const match = content.match(/^gitdir:[ \t]*(.+?)[ \t]*$/m);
156
+ if (match === null) {
157
+ throw new SubstrateResolveError(`substrate .git marker is malformed (no \`gitdir:\` line): ${gitFile}`);
158
+ }
159
+ // A relative `gitdir:` is interpreted by git relative to the working copy, so
160
+ // resolve it against `localPath` before checking cache-root containment.
161
+ const target = resolve(localPath, match[1]);
162
+ // Canonicalise both sides through `realpath` so any symlink along the path —
163
+ // notably an in-cache `gitdir:` symlinked to an out-of-cache repository that a
164
+ // string check would wrongly trust — is collapsed before the comparison. A
165
+ // missing/broken target (ENOENT/ENOTDIR) is not a real gitdir and is rejected.
166
+ let realTarget;
167
+ let realRoot;
168
+ try {
169
+ realTarget = await realpath(target);
170
+ realRoot = await realpath(cacheRoot);
171
+ }
172
+ catch (cause) {
173
+ if (isNodeErrno(cause, "ENOENT") || isNodeErrno(cause, "ENOTDIR")) {
174
+ throw new SubstrateResolveError(`substrate .git gitdir target is missing or broken: ${target}`, { cause });
175
+ }
176
+ throw new SubstrateResolveError(`substrate .git target is not accessible: ${target}`, { cause });
177
+ }
178
+ if (!isWithin(realRoot, realTarget)) {
179
+ throw new SubstrateResolveError(`substrate .git points outside the cache root and would escape it: ${gitFile}`);
180
+ }
181
+ if (!(await isDirectory(realTarget))) {
182
+ throw new SubstrateResolveError(`substrate .git gitdir target is not a directory: ${realTarget}`);
183
+ }
184
+ }
185
+ /**
186
+ * The default, git-only substrate backend. Clones the substrate on first use
187
+ * and, on subsequent resolutions, fetches and re-pins the working copy to the
188
+ * requested ref (branch tip, tag, or SHA) without clobbering unrelated state.
189
+ */
190
+ export class GitSubstrateBackend {
191
+ #git;
192
+ constructor(gitRunner = defaultGitRunner) {
193
+ this.#git = gitRunner;
194
+ }
195
+ /**
196
+ * Materialise the working copy at `options.localPath`: clone on first use,
197
+ * fetch + re-pin thereafter. `refresh: false` reuses an already-present
198
+ * working copy as-is — no fetch, no re-pin, no git invocation — so the
199
+ * returned handle's `ref` records the intended pin rather than a re-validated
200
+ * checkout (see {@link SubstrateResolveOptions.refresh}).
201
+ *
202
+ * Known limitation (deferred past slice S1): materialisation is NOT guarded by
203
+ * an inter-process lock. The resolver's single-flight coalescing only
204
+ * serialises resolves *within one process*, so two Nano instances that share a
205
+ * `cacheRoot` and resolve the same context concurrently can race the
206
+ * first-use `mkdir → clone → pin` (or `fetch → pin`) sequence for the same
207
+ * `localPath` — one process may observe the path as absent or
208
+ * partially-initialised while the other is still cloning, surfacing a spurious
209
+ * "not a git working copy" error or (worse) a half-written working copy.
210
+ * Cross-process sharing is therefore reliable only once the substrate is
211
+ * already materialised; the concurrent *first-use* case is a documented gap. A
212
+ * follow-up slice will add a per-`localPath` inter-process lock (bounded retry
213
+ * + stale-lock reclamation) to close it. See PR #309 / nano-ide binding notes.
214
+ */
215
+ async materialise(identity, options) {
216
+ const { localPath } = options;
217
+ const refresh = options.refresh ?? true;
218
+ // `SubstrateResolveOptions.localPath` is contractually absolute — the resolver
219
+ // derives it that way. Enforce it here too: a relative `localPath` from a
220
+ // direct backend caller would run clone/fetch/checkout relative to the process
221
+ // CWD, at an unpredictable location that also sidesteps the cache-root
222
+ // containment guards below. Reject it before any git command runs.
223
+ if (!isAbsolute(localPath)) {
224
+ throw new SubstrateResolveError(`substrate localPath must be absolute: ${localPath}`);
225
+ }
226
+ // A symlink AT `localPath` would make every subsequent `join(localPath, …)`
227
+ // and git invocation operate on the link's target — which can point outside
228
+ // the cache root at an arbitrary pre-existing working copy. Reject it before
229
+ // it is ever trusted as a clone, so resolution can never escape the cache
230
+ // root via a planted symlink.
231
+ if (await isSymlink(localPath)) {
232
+ throw new SubstrateResolveError(`substrate path is a symlink and would escape the cache root: ${localPath}`);
233
+ }
234
+ // Likewise, a `.git` symlink *inside* an otherwise-legitimate `localPath`
235
+ // would make git treat an arbitrary out-of-cache gitdir as this working
236
+ // copy's repository (cache escape). A real clone never has a symlinked
237
+ // `.git`, so reject one before it is trusted as "already cloned".
238
+ const gitDir = join(localPath, ".git");
239
+ if (await isSymlink(gitDir)) {
240
+ throw new SubstrateResolveError(`substrate .git is a symlink and would escape the cache root: ${gitDir}`);
241
+ }
242
+ // A genuine clone's `.git` is a *directory*. A `.git` that exists as
243
+ // anything else is a git worktree/submodule marker *file* pointing at a real
244
+ // gitdir elsewhere, which must be validated before it is trusted — an
245
+ // out-of-cache gitdir would be a cache escape of the same class as the
246
+ // `.git`-symlink case above.
247
+ let alreadyCloned = await isDirectory(gitDir);
248
+ if (!alreadyCloned && (await pathExists(gitDir))) {
249
+ // `.git` exists but is not a directory: a git worktree/submodule marker
250
+ // *file* pointing at a real gitdir elsewhere. Trust it as "already cloned"
251
+ // only when that gitdir is well-formed and stays inside the cache root;
252
+ // otherwise this throws a precise error naming the actual reason (malformed
253
+ // marker, missing/broken target, non-directory, or genuine cache escape).
254
+ await assertGitFileMarkerWithinRoot(gitDir, localPath, dirname(localPath));
255
+ alreadyCloned = true;
256
+ }
257
+ if (!alreadyCloned) {
258
+ // A pre-existing entry at `localPath` that is not a git working copy — a
259
+ // non-git directory, or a file/symlink — would make `git clone` fail with
260
+ // an opaque wrapped error. Detect any such entry and report it clearly.
261
+ if (await pathExists(localPath)) {
262
+ throw new SubstrateResolveError(`substrate path already exists but is not a git working copy: ${localPath}`);
263
+ }
264
+ await mkdir(dirname(localPath), { recursive: true });
265
+ // `--` separates options from operands so a `repo` starting with `-`
266
+ // (untrusted manifest input) can't be parsed as a git option.
267
+ await this.#git(["clone", "--", identity.repo, localPath]);
268
+ await this.#pin(localPath, identity.ref);
269
+ }
270
+ else if (refresh) {
271
+ // Cache reuse must NOT trust the recorded `origin`: an equivalent repo
272
+ // spelling with different auth (e.g. SSH vs HTTPS/token) won't take effect
273
+ // once the entry exists, and a tampered cache entry could point `origin`
274
+ // at an attacker-chosen remote that the resolver would then fetch from.
275
+ // Re-point `origin` at the manifest's `identity.repo` before every fetch
276
+ // so refreshes always operate on the expected remote. `--` separates
277
+ // options from the operand so a `repo` starting with `-` (untrusted
278
+ // manifest input) can't be parsed as a git option.
279
+ await this.#git(["remote", "set-url", "origin", "--", identity.repo], localPath);
280
+ await this.#git(["fetch", "--tags", "--prune", "--force", "origin"], localPath);
281
+ await this.#pin(localPath, identity.ref);
282
+ }
283
+ // On the `alreadyCloned && !refresh` path neither branch above ran, so the
284
+ // existing working copy is returned untouched (no fetch/pin). `ref` here is
285
+ // therefore the *requested* pin, not a re-verified checkout — the documented
286
+ // `refresh: false` contract (trust the existing working copy as-is).
287
+ return { identity, localPath, repo: identity.repo, ref: identity.ref };
288
+ }
289
+ /**
290
+ * Pin the working copy to `ref`. If `ref` names a remote branch, pin to the
291
+ * fetched remote tip (so a refresh moves the branch forward); otherwise treat
292
+ * `ref` as a tag or SHA and check it out detached.
293
+ */
294
+ async #pin(cwd, ref) {
295
+ const remoteBranch = await this.#hasRef(cwd, `origin/${ref}`);
296
+ if (remoteBranch) {
297
+ await this.#git(["checkout", "--force", "-B", ref, `origin/${ref}`], cwd);
298
+ }
299
+ else {
300
+ // `--detach` pins to the exact commit rather than attaching to any local
301
+ // branch that happens to share `ref`'s name; `--end-of-options` stops an
302
+ // option-style `ref` from being parsed as a git flag.
303
+ await this.#git(["checkout", "--force", "--detach", "--end-of-options", ref], cwd);
304
+ }
305
+ }
306
+ async #hasRef(cwd, ref) {
307
+ try {
308
+ // `--end-of-options` stops an option-style `ref` (manifest-controlled)
309
+ // from being parsed as a git flag — the same injection guard used by the
310
+ // `clone`/`checkout` paths.
311
+ await this.#git(["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`], cwd);
312
+ return true;
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ }
318
+ }
@@ -0,0 +1,42 @@
1
+ import type { ContextBinding } from "./descriptor.ts";
2
+ /**
3
+ * The resolved, canonical identity of a context binding.
4
+ *
5
+ * `key` is a stable, collision-resistant string: two bindings share a substrate
6
+ * **iff** their `key`s are equal. `repo` is the concrete clone URL derived from
7
+ * the binding's (possibly shorthand) `repo`, and `ref` is the pinned ref.
8
+ */
9
+ export interface ContextIdentity {
10
+ /**
11
+ * Stable canonical identity key. Equal keys ⇒ same shared substrate; distinct
12
+ * keys ⇒ private substrates. Safe to use as a map key.
13
+ */
14
+ readonly key: string;
15
+ /** The concrete clone URL (or local/`file://` path) for the substrate. */
16
+ readonly repo: string;
17
+ /** The pinned ref (branch / tag / SHA). */
18
+ readonly ref: string;
19
+ /**
20
+ * Filesystem-safe directory name derived deterministically from {@link key}.
21
+ * Two identities with the same key yield the same directory name, so a shared
22
+ * cache root gives them the same on-disk working copy.
23
+ */
24
+ readonly slug: string;
25
+ }
26
+ /**
27
+ * Compute the canonical {@link ContextIdentity} for a binding. Deterministic and
28
+ * pure: equal `(repo, ref)` names (after normalisation) always produce an equal
29
+ * `key` and `slug`. Distinct names are collision-resistant, not collision-proof:
30
+ * both `key` and `slug` are sha256-derived, so two distinct names collide only on
31
+ * a full sha256 collision (the `slug` embeds the whole `key`, so it is exactly as
32
+ * strong as the `key`).
33
+ */
34
+ export declare function resolveContextIdentity(binding: ContextBinding): ContextIdentity;
35
+ /**
36
+ * The stable identity key of a binding — the sharing primitive. Two bindings
37
+ * resolve to the same shared substrate iff this returns the same value for
38
+ * both.
39
+ */
40
+ export declare function contextIdentityKey(binding: ContextBinding): string;
41
+ /** `true` iff two bindings name the same shared context (same substrate). */
42
+ export declare function sameContext(a: ContextBinding, b: ContextBinding): boolean;