knodin 0.10.7 → 0.11.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/bin/cli.js +11 -4
- package/dist/src/compact-structural.js +8 -3
- package/dist/src/credential-patterns.js +14 -1
- package/dist/src/engine/git-layout.js +53 -0
- package/dist/src/engine/index.js +6 -0
- package/dist/src/engine/parse-pool.js +21 -4
- package/dist/src/engine/seal.js +122 -3
- package/dist/src/engine/sealed-open.js +125 -8
- package/dist/src/engine/text-matches.js +121 -79
- package/dist/src/init.js +28 -3
- package/dist/src/mcp-worker-supervisor.js +10 -2
- package/dist/src/structural-fast-path.js +64 -1
- package/dist/src/tools/knodin-tools.js +16 -9
- package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
- package/docs/releases/0.10.8.md +165 -0
- package/docs/releases/0.11.0.md +227 -0
- package/package.json +4 -2
package/dist/bin/cli.js
CHANGED
|
@@ -456,10 +456,17 @@ function formatStatusHuman(result) {
|
|
|
456
456
|
? result.missing.records[0]
|
|
457
457
|
: (result.missing.files[0] ?? result.missing.records[0]);
|
|
458
458
|
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
459
|
+
// A linked worktree with no database is the one case where `repair` is both
|
|
460
|
+
// the wrong first step and the expensive one: `init` seeds from an indexed
|
|
461
|
+
// sibling and reconciles only what differs, while `repair` builds from
|
|
462
|
+
// scratch. The engine has already worked out that this is that case, so
|
|
463
|
+
// defer to the step it wrote rather than recomputing the judgement here.
|
|
464
|
+
const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
|
|
465
|
+
const repairCommand = worktreeStep ??
|
|
466
|
+
(result.lifecycle?.status === "degraded" &&
|
|
467
|
+
result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
|
|
468
|
+
? "Run `knodin init`."
|
|
469
|
+
: "Run `knodin repair`.");
|
|
463
470
|
return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
|
|
464
471
|
}
|
|
465
472
|
function humanLabel(key) {
|
|
@@ -12,12 +12,17 @@ export function compactIdentity(identity) {
|
|
|
12
12
|
export function expandCompactIdentity(identity) {
|
|
13
13
|
return identity?.startsWith("~") ? `${IDENTITY_PREFIX}${identity.slice(1)}*` : identity;
|
|
14
14
|
}
|
|
15
|
+
// Missing evidence is "unknown", never "fresh" — the same default the engine's
|
|
16
|
+
// own `stalenessFor()` uses. Search stamps staleness per row and `SearchPage`
|
|
17
|
+
// carries no page-level field, so a zero-row page has no row to read it from:
|
|
18
|
+
// defaulting to "fresh" rendered an empty result on a stale or unprobed graph as
|
|
19
|
+
// `fresh 0/0`, an authoritative-looking negative (KNODIN-22).
|
|
15
20
|
function freshness(value) {
|
|
16
21
|
if (value === "reconciled")
|
|
17
22
|
return "reconciled";
|
|
18
|
-
if (value === "
|
|
19
|
-
return "
|
|
20
|
-
return "
|
|
23
|
+
if (value === "fresh")
|
|
24
|
+
return "fresh";
|
|
25
|
+
return "unknown";
|
|
21
26
|
}
|
|
22
27
|
function terseSignature(row) {
|
|
23
28
|
const signature = row.signature?.replace(/\s+/g, " ").trim() ?? "";
|
|
@@ -25,8 +25,21 @@ export function credentialPatterns() {
|
|
|
25
25
|
// consumed BEFORE the value, because `authorization: Bearer <token>`
|
|
26
26
|
// otherwise matches only the word "Bearer" — redacting the label and
|
|
27
27
|
// preserving the credential, exactly inverted (KNODIN-10).
|
|
28
|
+
//
|
|
29
|
+
// The keyword is surrounded by `[\w-]*` rather than anchored with `\b`,
|
|
30
|
+
// because `\b` does not exist between `_` and a letter: a leading `\b`
|
|
31
|
+
// could never match inside `AWS_SECRET_ACCESS_KEY`, and a trailing one
|
|
32
|
+
// stopped the name short of the `=`. That excluded the dominant spelling
|
|
33
|
+
// of every real secret — `AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`,
|
|
34
|
+
// `NPM_TOKEN=`, any `*_SECRET=` — while `password=` alone was redacted
|
|
35
|
+
// (KNODIN-19).
|
|
36
|
+
//
|
|
37
|
+
// The trailing half is `[_-]`-led rather than a bare `[\w-]*` so the
|
|
38
|
+
// keyword has to be a whole segment of the name. knodin echoes source
|
|
39
|
+
// into diagnostics and compressed output, and an unanchored tail
|
|
40
|
+
// redacted the value of a plain `const tokenCount = 5`.
|
|
28
41
|
label: "credential",
|
|
29
|
-
pattern:
|
|
42
|
+
pattern: /(?<![\w-])([\w-]*(?:authorization|password|passwd|secret|token|api[_-]?key)(?:[_-][\w-]*)?)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
|
|
30
43
|
preservedGroups: 2,
|
|
31
44
|
},
|
|
32
45
|
{
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Git checkout-layout questions, kept out of the engine on purpose.
|
|
5
|
+
*
|
|
6
|
+
* `engine/index.ts` carries a pinned budget on direct `fs` reads
|
|
7
|
+
* (`sealed-reader-inventory.spec.ts`) because a sealed artifact answers with no
|
|
8
|
+
* working tree: a reader that touches the filesystem there gets ENOENT, which
|
|
9
|
+
* the nearest guard turns into `""` or `null`, and that reads downstream as
|
|
10
|
+
* "this symbol has no body" rather than "this was not covered".
|
|
11
|
+
*
|
|
12
|
+
* Git *metadata* is categorically outside that concern — a sealed artifact has
|
|
13
|
+
* no `.git` at all, and these functions are never on a source-content path — so
|
|
14
|
+
* routing them through the sealed resolver would add a branch that can never
|
|
15
|
+
* execute. Keeping them here says that in the layout rather than by spending
|
|
16
|
+
* budget the engine reserves for source reads, and it makes them directly
|
|
17
|
+
* unit-testable, which they are not as engine-private helpers.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* True only for a `git worktree add` checkout.
|
|
21
|
+
*
|
|
22
|
+
* A linked worktree's `.git` is a FILE pointing at an administrative directory
|
|
23
|
+
* under the main checkout, and that directory contains `commondir` naming the
|
|
24
|
+
* shared repository.
|
|
25
|
+
*
|
|
26
|
+
* The `.git` file alone is NOT sufficient and treating it as sufficient is a
|
|
27
|
+
* live bug: submodules and `--separate-git-dir` clones use one too, and neither
|
|
28
|
+
* has a main checkout whose graph could cover it, so per-worktree guidance would
|
|
29
|
+
* be actively wrong for them. `commondir` is the marker that actually
|
|
30
|
+
* distinguishes the case.
|
|
31
|
+
*/
|
|
32
|
+
export function isLinkedWorktree(repo) {
|
|
33
|
+
const marker = path.join(repo, ".git");
|
|
34
|
+
try {
|
|
35
|
+
// An ordinary checkout keeps a `.git` directory; `statSync` throws when
|
|
36
|
+
// there is no `.git` at all, which is an ordinary case — knodin indexes
|
|
37
|
+
// plain directories too.
|
|
38
|
+
if (!fs.statSync(marker).isFile())
|
|
39
|
+
return false;
|
|
40
|
+
const pointer = fs.readFileSync(marker, "utf8").trim();
|
|
41
|
+
const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
|
|
42
|
+
if (!gitDir)
|
|
43
|
+
return false;
|
|
44
|
+
return fs.existsSync(path.join(path.resolve(repo, gitDir), "commondir"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Every read is inside the guard on purpose. This is best-effort detection
|
|
48
|
+
// reached from the missing-database remediation path, and that path has to
|
|
49
|
+
// answer: an unreadable `.git`, or one that disappears between the stat and
|
|
50
|
+
// the read, must degrade to "not a worktree" rather than crash `status`.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/src/engine/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidat
|
|
|
32
32
|
import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
|
|
33
33
|
import { walkRepoFiles } from "./file-walker.js";
|
|
34
34
|
import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
|
|
35
|
+
import { isLinkedWorktree } from "./git-layout.js";
|
|
35
36
|
// Runtime import, but not a cycle: parse-pool imports only TYPES from here,
|
|
36
37
|
// which erase at compile time. parse-worker's runtime import of this module
|
|
37
38
|
// resolves inside the worker thread, never in this one.
|
|
@@ -13083,6 +13084,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13083
13084
|
verification: { mode: "deep-audit", verifiedAt },
|
|
13084
13085
|
freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
|
|
13085
13086
|
repairSteps: [
|
|
13087
|
+
...(isLinkedWorktree(resolved)
|
|
13088
|
+
? [
|
|
13089
|
+
"Run `knodin init` here: graphs are per-worktree and this one has none of its own, so the main checkout's graph does not cover it. `init` seeds from an indexed sibling worktree where one exists rather than rebuilding from scratch.",
|
|
13090
|
+
]
|
|
13091
|
+
: []),
|
|
13086
13092
|
"Run `knodin repair` to create the local index.",
|
|
13087
13093
|
"Run `knodin status` again to verify health.",
|
|
13088
13094
|
],
|
|
@@ -147,10 +147,27 @@ class PoolRun {
|
|
|
147
147
|
return this.buckets.get(bestKey)?.pop() ?? null;
|
|
148
148
|
}
|
|
149
149
|
attach(slot) {
|
|
150
|
-
|
|
151
|
-
slot.
|
|
152
|
-
slot
|
|
153
|
-
|
|
150
|
+
// Bind these listeners to the handle they were registered on, not just to
|
|
151
|
+
// the slot. `PoolWorkerHandle` offers no listener removal, so a replaced
|
|
152
|
+
// worker's listeners stay live and still close over the slot — whose
|
|
153
|
+
// `.handle` is by then the replacement. A crashed worker's inevitable late
|
|
154
|
+
// `exit` therefore re-entered `onSlotFailure` and terminated its OWN
|
|
155
|
+
// replacement, cancelling the one documented retry and leaving the lane
|
|
156
|
+
// dead; on a one-worker pool that dropped the whole run to inline parsing
|
|
157
|
+
// (KNODIN-20). Mirrors the `pending.child !== child` guard the MCP
|
|
158
|
+
// supervisor already carries.
|
|
159
|
+
const handle = slot.handle;
|
|
160
|
+
const current = () => slot.handle === handle;
|
|
161
|
+
handle.onMessage((response) => {
|
|
162
|
+
if (current())
|
|
163
|
+
this.onResponse(slot, response);
|
|
164
|
+
});
|
|
165
|
+
handle.onError((error) => {
|
|
166
|
+
if (current())
|
|
167
|
+
this.onSlotFailure(slot, error.message);
|
|
168
|
+
});
|
|
169
|
+
handle.onExit((code) => {
|
|
170
|
+
if (current() && slot.inFlight)
|
|
154
171
|
this.onSlotFailure(slot, `worker exited with code ${code}`);
|
|
155
172
|
});
|
|
156
173
|
}
|
package/dist/src/engine/seal.js
CHANGED
|
@@ -22,16 +22,114 @@ const SEAL_SCHEMA = [
|
|
|
22
22
|
function countLines(buf) {
|
|
23
23
|
return buf.toString("utf8").split("\n").length;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A database claims to be an artifact but carries an attestation the reader
|
|
27
|
+
* cannot trust.
|
|
28
|
+
*
|
|
29
|
+
* Distinct from "no attestation at all", which is an ordinary index and a
|
|
30
|
+
* routine answer. Every field here is consumed as a decision downstream — the
|
|
31
|
+
* schema gate, the version gate, and the repository path the artifact is
|
|
32
|
+
* allowed to serve source for — so a half-populated object must stop at the
|
|
33
|
+
* reader rather than reach them.
|
|
34
|
+
*/
|
|
35
|
+
export class SealAttestationError extends Error {
|
|
36
|
+
code = "malformed-attestation";
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(reason);
|
|
39
|
+
this.name = "SealAttestationError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates the WHOLE shape, not just the fields the immediate caller reads.
|
|
47
|
+
* A sealed artifact is transported between machines, so it is untrusted input
|
|
48
|
+
* rather than something this process wrote (KNODIN-21), and an unchecked cast
|
|
49
|
+
* would let `undefined` stand where a version or a path belongs.
|
|
50
|
+
*/
|
|
51
|
+
function validateAttestation(value) {
|
|
52
|
+
const bad = (reason) => {
|
|
53
|
+
throw new SealAttestationError(`seal attestation is malformed: ${reason}`);
|
|
54
|
+
};
|
|
55
|
+
if (!isRecord(value))
|
|
56
|
+
return bad("not an object");
|
|
57
|
+
const str = (key, on) => typeof on[key] === "string" ? on[key] : bad(`${key} is not a string`);
|
|
58
|
+
const num = (key, on) => typeof on[key] === "number" && Number.isFinite(on[key])
|
|
59
|
+
? on[key]
|
|
60
|
+
: bad(`${key} is not a finite number`);
|
|
61
|
+
const nullableStr = (key, on) => on[key] === null || typeof on[key] === "string"
|
|
62
|
+
? on[key]
|
|
63
|
+
: bad(`${key} is not a string or null`);
|
|
64
|
+
const obj = (key) => isRecord(value[key]) ? value[key] : bad(`${key} is not an object`);
|
|
65
|
+
if (value.sealVersion !== 1)
|
|
66
|
+
bad(`unsupported sealVersion ${String(value.sealVersion)}`);
|
|
67
|
+
const repository = obj("repository");
|
|
68
|
+
const health = obj("health");
|
|
69
|
+
const source = obj("source");
|
|
70
|
+
const embeddings = obj("embeddings");
|
|
71
|
+
const policy = source.policy;
|
|
72
|
+
if (policy !== "all-indexed" && policy !== "excluded")
|
|
73
|
+
bad(`unsupported source policy ${String(policy)}`);
|
|
74
|
+
const stripped = embeddings.stripped;
|
|
75
|
+
if (typeof stripped !== "boolean")
|
|
76
|
+
bad("embeddings.stripped is not a boolean");
|
|
77
|
+
return {
|
|
78
|
+
sealVersion: 1,
|
|
79
|
+
knodinVersion: str("knodinVersion", value),
|
|
80
|
+
schemaVersion: num("schemaVersion", value),
|
|
81
|
+
repository: {
|
|
82
|
+
identity: str("identity", repository),
|
|
83
|
+
path: str("path", repository),
|
|
84
|
+
remoteUrl: nullableStr("remoteUrl", repository),
|
|
85
|
+
},
|
|
86
|
+
sealedCommit: str("sealedCommit", value),
|
|
87
|
+
sealedRef: nullableStr("sealedRef", value),
|
|
88
|
+
sealedAt: str("sealedAt", value),
|
|
89
|
+
health: {
|
|
90
|
+
status: str("status", health),
|
|
91
|
+
pendingPaths: num("pendingPaths", health),
|
|
92
|
+
freshness: str("freshness", health),
|
|
93
|
+
},
|
|
94
|
+
source: {
|
|
95
|
+
policy: policy,
|
|
96
|
+
files: num("files", source),
|
|
97
|
+
uniqueBlobs: num("uniqueBlobs", source),
|
|
98
|
+
rawBytes: num("rawBytes", source),
|
|
99
|
+
storedBytes: num("storedBytes", source),
|
|
100
|
+
},
|
|
101
|
+
embeddings: { stripped: stripped, removed: num("removed", embeddings) },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `null` when the database carries no attestation; THROWS
|
|
106
|
+
* `SealAttestationError` when it carries one that does not validate. The split
|
|
107
|
+
* has teeth: "ordinary index" is something a caller falls back from, while a
|
|
108
|
+
* database misrepresenting itself must not be quietly downgraded to the same
|
|
109
|
+
* answer.
|
|
110
|
+
*/
|
|
25
111
|
export function readSealAttestation(db) {
|
|
112
|
+
let raw;
|
|
26
113
|
try {
|
|
27
114
|
const row = db
|
|
28
115
|
.query("SELECT value FROM meta WHERE key = 'sealAttestation'")
|
|
29
116
|
.get();
|
|
30
|
-
|
|
117
|
+
if (!row)
|
|
118
|
+
return null;
|
|
119
|
+
raw = row.value;
|
|
31
120
|
}
|
|
32
121
|
catch {
|
|
122
|
+
// No `meta` table, or an unreadable one: not a sealed artifact.
|
|
33
123
|
return null;
|
|
34
124
|
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(raw);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new SealAttestationError(`seal attestation is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
return validateAttestation(parsed);
|
|
35
133
|
}
|
|
36
134
|
/**
|
|
37
135
|
* Reads embedded source out of a sealed artifact.
|
|
@@ -64,9 +162,30 @@ export function listSealedFiles(db) {
|
|
|
64
162
|
.all()
|
|
65
163
|
.map((row) => row.filePath);
|
|
66
164
|
}
|
|
67
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Content hash of every embedded file, as recorded at seal time.
|
|
167
|
+
*
|
|
168
|
+
* The hash is over the RAW bytes, so a caller can compare against a file on
|
|
169
|
+
* disk without decompressing the blob.
|
|
170
|
+
*/
|
|
171
|
+
export function listSealedFileHashes(db) {
|
|
172
|
+
return db
|
|
173
|
+
.query("SELECT filePath, sha256 FROM sealed_file ORDER BY filePath")
|
|
174
|
+
.all();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* True when this database carries embedded source, i.e. can answer with no
|
|
178
|
+
* checkout. A malformed attestation answers false: it is not an artifact any
|
|
179
|
+
* reader here can serve from, and this predicate's callers route on it rather
|
|
180
|
+
* than handle it.
|
|
181
|
+
*/
|
|
68
182
|
export function isSealedDatabase(db) {
|
|
69
|
-
|
|
183
|
+
try {
|
|
184
|
+
return readSealAttestation(db) !== null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
70
189
|
}
|
|
71
190
|
/**
|
|
72
191
|
* Produce a sealed artifact, or refuse with a machine-readable code.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
|
|
5
6
|
import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
|
|
6
|
-
import { readSealAttestation } from "./seal.js";
|
|
7
|
+
import { listSealedFileHashes, readSealAttestation, SealAttestationError, } from "./seal.js";
|
|
7
8
|
import { Database } from "./sqlite.js";
|
|
8
9
|
export function openSealedArtifact(artifactPath, options = {}) {
|
|
9
10
|
const resolved = path.resolve(artifactPath);
|
|
@@ -20,7 +21,18 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
20
21
|
reason: error instanceof Error ? error.message : String(error),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
let attestation;
|
|
25
|
+
try {
|
|
26
|
+
attestation = readSealAttestation(db);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
db.close();
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: error instanceof SealAttestationError ? "malformed-attestation" : "unreadable",
|
|
33
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
24
36
|
if (!attestation) {
|
|
25
37
|
db.close();
|
|
26
38
|
return {
|
|
@@ -84,10 +96,31 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
84
96
|
// only the mount would leave those internal reads falling through to a
|
|
85
97
|
// filesystem that does not have the source, which fails as "" rather than
|
|
86
98
|
// loudly.
|
|
87
|
-
registerSealedRepository(mountDir, db);
|
|
88
99
|
const attestedPath = attestation.repository.path;
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
const registerAttested = attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir);
|
|
101
|
+
// The attested path is chosen by the artifact, and the registry it lands in
|
|
102
|
+
// takes precedence over the filesystem for every source read in the
|
|
103
|
+
// process. An artifact naming a path that exists here would therefore serve
|
|
104
|
+
// its own bytes to live queries against that checkout (KNODIN-21). Sealing
|
|
105
|
+
// and opening on ONE machine is a real flow, so this refuses on
|
|
106
|
+
// DISAGREEMENT rather than on existence: where the embedded blobs match the
|
|
107
|
+
// files on disk the artifact describes that tree and shadowing it changes
|
|
108
|
+
// nothing, and where they do not, whichever side is wrong, no read of that
|
|
109
|
+
// path can be trusted.
|
|
110
|
+
if (registerAttested) {
|
|
111
|
+
const conflict = firstSourceDisagreement(db, path.resolve(attestedPath));
|
|
112
|
+
if (conflict) {
|
|
113
|
+
db.close();
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "path-conflict",
|
|
117
|
+
reason: `artifact attests ${path.resolve(attestedPath)}, which exists here with different source (${conflict}); re-seal from this checkout, or open the artifact where that path is absent`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
acquireSealedRepository(mountDir, db);
|
|
122
|
+
if (registerAttested)
|
|
123
|
+
acquireSealedRepository(attestedPath, db);
|
|
91
124
|
const sealedAt = Date.parse(attestation.sealedAt);
|
|
92
125
|
const ageDays = Number.isNaN(sealedAt)
|
|
93
126
|
? 0
|
|
@@ -100,13 +133,97 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
100
133
|
degraded,
|
|
101
134
|
ageDays,
|
|
102
135
|
close() {
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
136
|
+
releaseSealedRepository(mountDir, db);
|
|
137
|
+
if (registerAttested)
|
|
138
|
+
releaseSealedRepository(attestedPath, db);
|
|
106
139
|
db.close();
|
|
107
140
|
},
|
|
108
141
|
};
|
|
109
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Open handles per registered path, most recent last.
|
|
145
|
+
*
|
|
146
|
+
* The engine's registry is a bare path→database map, so two artifacts
|
|
147
|
+
* attesting the same repository would have the first `close()` unregister the
|
|
148
|
+
* other's source and leave it reading `""` (KNODIN-21). Held here rather than
|
|
149
|
+
* widened there because the map is the engine's chokepoint for a single
|
|
150
|
+
* answer to "where do bytes for this path come from"; ownership of the
|
|
151
|
+
* handles that claim it belongs to whoever opened them.
|
|
152
|
+
*/
|
|
153
|
+
const sealedRegistrations = new Map();
|
|
154
|
+
function acquireSealedRepository(repoPath, db) {
|
|
155
|
+
const key = path.resolve(repoPath);
|
|
156
|
+
const held = sealedRegistrations.get(key) ?? [];
|
|
157
|
+
held.push(db);
|
|
158
|
+
sealedRegistrations.set(key, held);
|
|
159
|
+
registerSealedRepository(key, db);
|
|
160
|
+
}
|
|
161
|
+
function releaseSealedRepository(repoPath, db) {
|
|
162
|
+
const key = path.resolve(repoPath);
|
|
163
|
+
const held = sealedRegistrations.get(key);
|
|
164
|
+
if (!held)
|
|
165
|
+
return;
|
|
166
|
+
const last = held.lastIndexOf(db);
|
|
167
|
+
if (last === -1)
|
|
168
|
+
return;
|
|
169
|
+
held.splice(last, 1);
|
|
170
|
+
// Re-point at a surviving handle rather than leaving the closed one
|
|
171
|
+
// registered: an unregister here would fall through to a filesystem that
|
|
172
|
+
// has no source, and a closed database would throw on the next read.
|
|
173
|
+
const survivor = held.at(-1);
|
|
174
|
+
if (survivor)
|
|
175
|
+
registerSealedRepository(key, survivor);
|
|
176
|
+
else {
|
|
177
|
+
sealedRegistrations.delete(key);
|
|
178
|
+
unregisterSealedRepository(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* First embedded file whose bytes differ from the file at the same relative
|
|
183
|
+
* path under `dir`, or `null` when every one that exists there agrees.
|
|
184
|
+
*
|
|
185
|
+
* Files absent from `dir` are not disagreement — a partially or wholly removed
|
|
186
|
+
* checkout is precisely what an artifact is for. Compared by hash against the
|
|
187
|
+
* one recorded at seal time, which is over raw bytes, so nothing is
|
|
188
|
+
* decompressed to answer this.
|
|
189
|
+
*
|
|
190
|
+
* `filePath` comes out of the artifact and is therefore attacker-controlled, so
|
|
191
|
+
* every candidate is resolved and required to stay under `dir` before it is
|
|
192
|
+
* opened. A `..` segment would otherwise have this check read and hash files
|
|
193
|
+
* outside the attested checkout — reachable during `openSealedArtifact`, and
|
|
194
|
+
* pure attack surface, since a repository-relative path never needs to escape.
|
|
195
|
+
* An entry that does is reported as disagreement rather than skipped: it is not
|
|
196
|
+
* a path this artifact can honestly claim, and failing closed keeps a traversal
|
|
197
|
+
* from being a way to hide an entry from this comparison.
|
|
198
|
+
*/
|
|
199
|
+
function firstSourceDisagreement(db, dir) {
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = fs.statSync(dir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (!stat.isDirectory())
|
|
208
|
+
return null;
|
|
209
|
+
const root = path.resolve(dir);
|
|
210
|
+
for (const entry of listSealedFileHashes(db)) {
|
|
211
|
+
const candidate = path.resolve(root, entry.filePath);
|
|
212
|
+
if (candidate !== root && !candidate.startsWith(root + path.sep))
|
|
213
|
+
return entry.filePath;
|
|
214
|
+
let onDisk;
|
|
215
|
+
try {
|
|
216
|
+
onDisk = fs.readFileSync(candidate);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const hash = crypto.createHash("sha256").update(onDisk).digest("hex");
|
|
222
|
+
if (hash !== entry.sha256)
|
|
223
|
+
return entry.filePath;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
110
227
|
/**
|
|
111
228
|
* Read at call time rather than imported as a constant so that a test can
|
|
112
229
|
* exercise the soft gate without rebuilding the package version.
|