@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.
- 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/dist/runtime/core/manifest.d.ts +24 -1
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +108 -11
- package/dist/runtime/core/validate.js +15 -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
- package/src/runtime/core/manifest.ts +22 -1
- package/src/runtime/core/modules/instance-tracking.test.ts +192 -6
- package/src/runtime/core/modules/instance-tracking.ts +115 -11
- package/src/runtime/core/validate.test.ts +46 -0
- 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
|
+
}
|