@rightkit/release 0.2.120 → 0.2.122
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/build-release.mjs +6 -2
- package/cli/right-release.mjs +12 -0
- package/committed-source.mjs +200 -0
- package/ownership-approval-command.mjs +39 -0
- package/ownership-approval.mjs +370 -0
- package/ownership-command.mjs +50 -14
- package/ownership-default-policy.json +21 -0
- package/ownership-evidence.mjs +224 -0
- package/ownership-git-inventory.mjs +195 -0
- package/ownership-helper-paths.json +11 -0
- package/ownership-key-authority.mjs +85 -0
- package/ownership-key-command.mjs +530 -0
- package/ownership-key-log.mjs +185 -0
- package/ownership-key-trust.mjs +326 -0
- package/ownership-report-command.mjs +87 -0
- package/ownership-report.mjs +144 -0
- package/ownership-scan-worker.mjs +15 -0
- package/ownership.mjs +504 -197
- package/package.json +4 -1
- package/preflight.mjs +19 -16
- package/publication-gate.mjs +31 -3
- package/publish-cargo.mjs +5 -0
- package/release.mjs +1 -1
- package/rightkit-drift.mjs +112 -0
- package/rightkit-ownership.json +131 -0
- package/rightkit-sync.mjs +150 -0
- package/rightkit-versions.json +111 -26
- package/upload-release.mjs +3 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { catalogDigest as scanCatalogDigest, checkApp, checkCommittedApp, MANIFEST_FILE, SCANNER_VERSION } from "./ownership.mjs";
|
|
7
|
+
import { acquireCommittedSource, assertCommittedCheckoutClean, committedSourceDataForInternalUse } from "./committed-source.mjs";
|
|
8
|
+
import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
|
|
9
|
+
import { canonicalRepositoryIdentity, loadTrustedExternal, resolveKeyLogTrust, sha256, validateOwnershipFloor, verifyRepositoryRegistration } from "./ownership-approval.mjs";
|
|
10
|
+
|
|
11
|
+
const digestFile = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
12
|
+
|
|
13
|
+
function git(root, args, runner = spawnSync) {
|
|
14
|
+
const result = runner("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true });
|
|
15
|
+
if (result?.status !== 0) throw new Error(`ownership evidence: git ${args.join(" ")} failed`);
|
|
16
|
+
return String(result.stdout).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rootContains(root, candidate) {
|
|
20
|
+
return root === "." || candidate === root || candidate.startsWith(`${root}/`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createEvidence(repoRoot, {
|
|
24
|
+
scanRoot = repoRoot,
|
|
25
|
+
manifestPath = path.join(path.dirname(fileURLToPath(import.meta.url)), MANIFEST_FILE),
|
|
26
|
+
scan = checkApp,
|
|
27
|
+
trustLoader = loadTrustedExternal,
|
|
28
|
+
gitRunner = spawnSync,
|
|
29
|
+
committed = false,
|
|
30
|
+
requireCleanCheckout = false,
|
|
31
|
+
allowPendingAdvisory = false,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const root = path.resolve(repoRoot);
|
|
34
|
+
const manifestBytes = readFileSync(manifestPath);
|
|
35
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
36
|
+
let trustState = trustLoader();
|
|
37
|
+
const pendingWithoutTrust = trustState.pending === true && trustState.enrolled === false && trustState.trust === null
|
|
38
|
+
&& trustState.protection?.ok === false && trustState.protection?.reason === "external trust file is absent";
|
|
39
|
+
if (trustState.protection?.ok !== true && !pendingWithoutTrust) throw new Error("protected ownership trust location was not verified");
|
|
40
|
+
if (!trustState.trust && !pendingWithoutTrust) throw new Error("protected ownership trust registry is missing");
|
|
41
|
+
let enrollmentPending = trustState.enrolled !== true;
|
|
42
|
+
if (committed && enrollmentPending && !allowPendingAdvisory && !trustState.trust?.rootFingerprint) throw new Error("protected ownership evidence requires both distinct, unrevoked approval keys to be enrolled");
|
|
43
|
+
// A valid protected registry with incomplete key enrollment can still drive
|
|
44
|
+
// a useful local backlog scan. It cannot produce a source binding or verify
|
|
45
|
+
// approval signatures, so this result is deliberately advisory.
|
|
46
|
+
const useCommittedSource = committed && (!enrollmentPending || Boolean(trustState.trust?.rootFingerprint));
|
|
47
|
+
if (useCommittedSource && requireCleanCheckout) assertCommittedCheckoutClean(root);
|
|
48
|
+
const source = useCommittedSource ? acquireCommittedSource(root, { requiredPaths: [...(trustState.trust?.provenance ?? []).flatMap((record) => record.paths ?? []), ...(trustState.trust?.rootFingerprint && !trustState.trust?.keyAuthority ? [".rightkit/ownership-keys.json"] : [])] }) : null;
|
|
49
|
+
let keyLogEvidence = null;
|
|
50
|
+
if (trustState.trust?.keyAuthority) {
|
|
51
|
+
const resolved = readCommittedKeyAuthority(trustState.trust);
|
|
52
|
+
if (!resolved.ok) throw new Error(`protected ownership key authority failed: ${resolved.problems.join("; ")}`);
|
|
53
|
+
trustState = { ...trustState, trust: resolved.resolvedTrust, enrolled: resolved.resolvedTrust.enrollmentComplete, pending: !resolved.resolvedTrust.enrollmentComplete };
|
|
54
|
+
enrollmentPending = trustState.pending;
|
|
55
|
+
keyLogEvidence = { keyLogDigest: resolved.digest, keyCheckpoint: resolved.keyCheckpoint, rootFingerprint: resolved.rootFingerprint, keyAuthority: resolved.authority };
|
|
56
|
+
if (!resolved.resolvedTrust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
|
|
57
|
+
} else if (trustState.trust?.rootFingerprint) {
|
|
58
|
+
const keySource = source ?? acquireCommittedSource(root, { requiredPaths: [".rightkit/ownership-keys.json"] });
|
|
59
|
+
const sourceData = committedSourceDataForInternalUse(keySource);
|
|
60
|
+
if (!sourceData) throw new Error("protected ownership key log requires authenticated committed source");
|
|
61
|
+
const entry = sourceData.entries.find((item) => item.rel === ".rightkit/ownership-keys.json");
|
|
62
|
+
const resolved = resolveKeyLogTrust(trustState.trust, entry?.bytes);
|
|
63
|
+
if (!resolved.ok) throw new Error(`protected ownership key log failed: ${resolved.problems.join("; ")}`);
|
|
64
|
+
trustState = { ...trustState, trust: resolved.trust, enrolled: resolved.trust.enrollmentComplete, pending: !resolved.trust.enrollmentComplete };
|
|
65
|
+
enrollmentPending = trustState.pending;
|
|
66
|
+
keyLogEvidence = { keyLogDigest: resolved.keyLogDigest, keyCheckpoint: resolved.checkpoint, rootFingerprint: resolved.trust.rootFingerprint };
|
|
67
|
+
if (!resolved.trust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
|
|
68
|
+
}
|
|
69
|
+
if (useCommittedSource && requireCleanCheckout) assertCommittedCheckoutClean(root, { expectedCommit: source.commit, expectedTree: source.tree });
|
|
70
|
+
const commit = useCommittedSource ? source.commit : git(root, ["rev-parse", "HEAD"], gitRunner);
|
|
71
|
+
const tree = useCommittedSource ? source.tree : git(root, ["rev-parse", `${commit}^{tree}`], gitRunner);
|
|
72
|
+
const scannerVersion = SCANNER_VERSION;
|
|
73
|
+
const catalogVersion = manifest.version ?? String(manifest.schema ?? "1");
|
|
74
|
+
const catalogDigest = scanCatalogDigest(manifest);
|
|
75
|
+
// When a protected registry exists, key enrollment controls signing
|
|
76
|
+
// authority; scanner and catalog floors still apply during pending enrollment.
|
|
77
|
+
const floor = trustState.trust
|
|
78
|
+
? validateOwnershipFloor(trustState.trust, { scannerVersion, catalogVersion, catalogDigest })
|
|
79
|
+
: { ok: false, problems: ["protected ownership floor is unavailable until trust registry enrollment"] };
|
|
80
|
+
// Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
|
|
81
|
+
const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
82
|
+
const requestedRoot = path.relative(real(root), real(scanRoot)).replaceAll("\\", "/") || ".";
|
|
83
|
+
if (requestedRoot.startsWith("../") || requestedRoot === ".." || path.isAbsolute(requestedRoot)) throw new Error("ownership evidence scan root must be inside repository");
|
|
84
|
+
const evidenceDiagnostics = [];
|
|
85
|
+
let repositoryIdentity = null;
|
|
86
|
+
let registrationCheck = null;
|
|
87
|
+
if (trustState.trust) {
|
|
88
|
+
try {
|
|
89
|
+
const origin = useCommittedSource ? source.origin : git(root, ["remote", "get-url", "origin"], gitRunner);
|
|
90
|
+
repositoryIdentity = canonicalRepositoryIdentity(origin);
|
|
91
|
+
registrationCheck = verifyRepositoryRegistration(trustState.trust, repositoryIdentity);
|
|
92
|
+
if (!registrationCheck.ok) {
|
|
93
|
+
if (!enrollmentPending) throw new Error(registrationCheck.problems.join("; "));
|
|
94
|
+
evidenceDiagnostics.push(...registrationCheck.problems);
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!enrollmentPending) throw error;
|
|
98
|
+
evidenceDiagnostics.push(`protected registration unavailable during pending enrollment: ${error.message}`);
|
|
99
|
+
repositoryIdentity = null;
|
|
100
|
+
registrationCheck = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const registration = registrationCheck?.registration ?? null;
|
|
104
|
+
const requestedRootRegistered = registration?.roots.some((registered) => rootContains(registered, requestedRoot)) ?? false;
|
|
105
|
+
if (registration && !requestedRootRegistered) {
|
|
106
|
+
if (!enrollmentPending) throw new Error(`release app root is outside protected ownership roots: ${requestedRoot}`);
|
|
107
|
+
evidenceDiagnostics.push(`requested scan root is outside protected ownership roots during pending enrollment: ${requestedRoot}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The release app's manifest/config can be nested, but the external registry
|
|
111
|
+
// defines repository coverage. Scan every protected root so a nested release
|
|
112
|
+
// cannot omit sibling packages or engine source by narrowing scanRoot.
|
|
113
|
+
const scanRoots = registration?.roots ? [...registration.roots] : [];
|
|
114
|
+
if (!requestedRootRegistered && !scanRoots.includes(requestedRoot)) scanRoots.push(requestedRoot);
|
|
115
|
+
const scans = (scanRoots.length ? scanRoots : [requestedRoot]).map((registered) => {
|
|
116
|
+
const absolute = registered === "." ? root : path.resolve(root, ...registered.split("/"));
|
|
117
|
+
const scanned = useCommittedSource
|
|
118
|
+
? checkCommittedApp(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source })
|
|
119
|
+
: scan(absolute, manifest, { trust: { trust: trustState.trust, enrolled: trustState.enrolled, pending: enrollmentPending }, scannerVersion, catalogVersion, ...(enrollmentPending ? { enrollmentPendingAdvisory: true } : {}) });
|
|
120
|
+
if (repositoryIdentity && canonicalRepositoryIdentity(scanned.metadata?.repositoryIdentity) !== repositoryIdentity) throw new Error(`ownership evidence: scanner observed a different repository identity under ${registered}`);
|
|
121
|
+
if (useCommittedSource && scanned.metadata?.commit !== commit) throw new Error(`ownership evidence: scanner observed a different source commit under ${registered}`);
|
|
122
|
+
if (scanned.metadata?.catalogDigest !== catalogDigest) throw new Error(`ownership evidence: scanner observed a different catalog under ${registered}`);
|
|
123
|
+
if (keyLogEvidence?.keyAuthority && JSON.stringify(scanned.metadata?.keyAuthority) !== JSON.stringify(keyLogEvidence.keyAuthority)) throw new Error(`ownership evidence: scanner observed a different shared key authority under ${registered}`);
|
|
124
|
+
const repositoryPaths = scanned.metadata?.pathBase === "repository";
|
|
125
|
+
const prefix = registered === "." || repositoryPaths ? "" : `${registered}/`;
|
|
126
|
+
return { root: registered, scanned, prefix };
|
|
127
|
+
});
|
|
128
|
+
const rebase = (entry, prefix) => ({ ...entry, ...(entry.path ? { path: `${prefix}${entry.path}` } : {}), ...(entry.file ? { file: `${prefix}${entry.file}` } : {}) });
|
|
129
|
+
const contentCoverage = scans.flatMap(({ scanned, prefix }) => (scanned.metadata?.coverage ?? []).map((entry) => rebase(entry, prefix)));
|
|
130
|
+
const contentDigests = scans.flatMap(({ scanned, prefix }) => (scanned.metadata?.contentDigests ?? []).map((entry) => ({ ...entry, path: `${prefix}${entry.path}` }))).sort((a, b) => a.path.localeCompare(b.path));
|
|
131
|
+
const contentDigest = sha256(contentDigests);
|
|
132
|
+
const approvals = scans.map(({ root: registered, scanned }) => {
|
|
133
|
+
if (useCommittedSource) return { root: registered, digest: scanned.metadata?.exceptionDigest ?? sha256([]) };
|
|
134
|
+
const exceptionsPath = path.join(root, ...(registered === "." ? [] : registered.split("/")), ".rightkit", "ownership-exceptions.json");
|
|
135
|
+
try { return { root: registered, digest: digestFile(exceptionsPath) }; }
|
|
136
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; return { root: registered, digest: sha256([]) }; }
|
|
137
|
+
});
|
|
138
|
+
const approvalsDigest = enrollmentPending ? null : sha256(approvals);
|
|
139
|
+
const result = {
|
|
140
|
+
ok: scans.every(({ scanned }) => scanned.ok),
|
|
141
|
+
findings: scans.flatMap(({ scanned, prefix }) => (scanned.findings ?? []).map((entry) => rebase(entry, prefix))),
|
|
142
|
+
violations: scans.flatMap(({ scanned, prefix }) => (scanned.violations ?? []).map((entry) => rebase(entry, prefix))),
|
|
143
|
+
problems: [...scans.flatMap(({ scanned }) => scanned.problems ?? []), ...evidenceDiagnostics, ...(enrollmentPending ? floor.problems : [])],
|
|
144
|
+
rows: scans.flatMap(({ scanned, root: registered }) => (scanned.rows ?? []).map((row) => ({ ...row, root: registered }))),
|
|
145
|
+
covered: scans.flatMap(({ scanned, prefix }) => (scanned.covered ?? []).map((entry) => rebase(entry, prefix))),
|
|
146
|
+
coverage: contentCoverage,
|
|
147
|
+
metadata: {
|
|
148
|
+
repositoryIdentity, commit, tree, catalogDigest, contentDigests, coverage: contentCoverage,
|
|
149
|
+
pathBase: "repository", pathPrefix: "",
|
|
150
|
+
roots: scans.map(({ root: registered, scanned }) => ({ root: registered, metadata: scanned.metadata })),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const evidence = {
|
|
154
|
+
schema: 1,
|
|
155
|
+
repositoryIdentity: repositoryIdentity ? canonicalRepositoryIdentity(result.metadata?.repositoryIdentity) : null,
|
|
156
|
+
source: enrollmentPending ? "enrollment-pending-advisory" : useCommittedSource ? "committed-git-tree" : "mutable-checkout-test-seam",
|
|
157
|
+
...(enrollmentPending ? {} : { commit, tree }),
|
|
158
|
+
catalogDigest,
|
|
159
|
+
catalogVersion,
|
|
160
|
+
scannerVersion,
|
|
161
|
+
approvalsDigest,
|
|
162
|
+
trustEnrolled: !enrollmentPending,
|
|
163
|
+
...(keyLogEvidence ?? {}),
|
|
164
|
+
floorOk: floor.ok,
|
|
165
|
+
visibility: registration?.visibility ?? null,
|
|
166
|
+
registrationDigest: registration ? sha256(registration) : null,
|
|
167
|
+
registeredRoots: registration ? [...registration.roots] : [],
|
|
168
|
+
contentDigest: enrollmentPending ? null : contentDigest,
|
|
169
|
+
checkedAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
const warning = enrollmentPending ? "WARNING: ownership approval enrollment is pending; findings remain backlog diagnostics and no protected evidence or approvals are asserted" : undefined;
|
|
172
|
+
return { ok: enrollmentPending || (result.ok && floor.ok && contentCoverage.length === 0), ...(enrollmentPending ? { enrollmentPending: true, warning } : {}), evidence, result, floor, contentCoverage };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Create scan evidence bound to committed source, catalog, and signed exceptions. */
|
|
176
|
+
export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
|
|
177
|
+
return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Alias spelling makes the immutable-source contract explicit at adapter boundaries. */
|
|
181
|
+
export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
|
|
182
|
+
return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Injection seam is deliberately named for tests and is not used by release commands.
|
|
186
|
+
export function createOwnershipEvidenceForTest(repoRoot, options) {
|
|
187
|
+
return createEvidence(repoRoot, options);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Test-only access to the production committed-tree evidence path. */
|
|
191
|
+
export function createCommittedOwnershipEvidenceForTest(repoRoot, options = {}) {
|
|
192
|
+
return createEvidence(repoRoot, { ...options, committed: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Entry point for an OS-protected local hook/adapter on private repositories. */
|
|
196
|
+
export function runProtectedLocalOwnershipCheck(repoRoot, scanRoot = repoRoot) {
|
|
197
|
+
const checked = createCommittedOwnershipEvidence(repoRoot, scanRoot);
|
|
198
|
+
if (checked.evidence.visibility !== "private") throw new Error("protected local ownership adapter only accepts registered private repositories");
|
|
199
|
+
return checked;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Re-scan exact source and compare every sealed binding before release upload. */
|
|
203
|
+
export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot) {
|
|
204
|
+
return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function verifyOwnershipEvidenceForTest(expected, repoRoot, options) {
|
|
208
|
+
return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createEvidence(repoRoot, options));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent) {
|
|
212
|
+
if (!expected || expected.schema !== 1) return { ok: false, reason: "sealed ownership evidence is missing or malformed" };
|
|
213
|
+
let current;
|
|
214
|
+
try { current = createCurrent(); }
|
|
215
|
+
catch (error) { return { ok: false, reason: error.message }; }
|
|
216
|
+
for (const key of ["repositoryIdentity", "source", "commit", "tree", "catalogDigest", "catalogVersion", "scannerVersion", "approvalsDigest", "keyLogDigest", "keyCheckpoint", "rootFingerprint", "keyAuthority", "visibility", "registrationDigest", "registeredRoots", "contentDigest"]) {
|
|
217
|
+
const unchanged = key === "registeredRoots" || key === "keyCheckpoint" || key === "keyAuthority"
|
|
218
|
+
? JSON.stringify(expected[key]) === JSON.stringify(current.evidence[key])
|
|
219
|
+
: expected[key] === current.evidence[key];
|
|
220
|
+
if (!unchanged) return { ok: false, reason: `ownership evidence ${key} changed after build` };
|
|
221
|
+
}
|
|
222
|
+
if (!current.ok) return { ok: false, reason: "current ownership scan fails or has incomplete coverage" };
|
|
223
|
+
return { ok: true, evidence: current.evidence };
|
|
224
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Working-tree inventory constrained to Git's tracked & non-ignored path set.
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { sha256 } from "./ownership-approval.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_LIST_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_BLOB_BATCH_BYTES = 256 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const CACHE_LIMIT = 128 * 1024 * 1024;
|
|
12
|
+
const blobCache = new Map();
|
|
13
|
+
let cacheBytes = 0;
|
|
14
|
+
|
|
15
|
+
function gitEnv() {
|
|
16
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
|
|
17
|
+
Object.assign(env, { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" });
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function runGit(root, repositoryRoot, args, { input, maxBuffer = MAX_LIST_BYTES } = {}) {
|
|
22
|
+
const executable = process.platform === "win32" ? "C:\\Program Files\\Git\\cmd\\git.exe" : "/usr/bin/git";
|
|
23
|
+
const result = spawnSync(executable, ["-C", root, "--no-replace-objects", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "-c", `safe.directory=${repositoryRoot}`, ...args], {
|
|
24
|
+
encoding: "buffer", env: gitEnv(), windowsHide: true, maxBuffer, timeout: 2500, ...(input ? { input } : {}),
|
|
25
|
+
});
|
|
26
|
+
if (result?.error || result?.status !== 0) throw new Error(`ownership inventory: git ${args[0]} failed${result?.error?.code ? ` (${result.error.code})` : ""}`);
|
|
27
|
+
return result.stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function remember(oid, bytes) {
|
|
31
|
+
if (bytes.length > CACHE_LIMIT) return;
|
|
32
|
+
const key = `gitblob:${oid}`;
|
|
33
|
+
if (blobCache.has(key)) { cacheBytes -= blobCache.get(key).length; blobCache.delete(key); }
|
|
34
|
+
blobCache.set(key, bytes); cacheBytes += bytes.length;
|
|
35
|
+
while (cacheBytes > CACHE_LIMIT && blobCache.size) {
|
|
36
|
+
const first = blobCache.keys().next().value;
|
|
37
|
+
cacheBytes -= blobCache.get(first).length; blobCache.delete(first);
|
|
38
|
+
}
|
|
39
|
+
return blobCache.get(key);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decodePath(bytes) {
|
|
43
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readGitBlobs(root, repositoryRoot, oids) {
|
|
47
|
+
if (!oids.length) return new Map();
|
|
48
|
+
const input = Buffer.from(`${oids.join("\n")}\n`);
|
|
49
|
+
const output = runGit(root, repositoryRoot, ["cat-file", "--batch"], { input, maxBuffer: MAX_BLOB_BATCH_BYTES });
|
|
50
|
+
const result = new Map(); let offset = 0;
|
|
51
|
+
for (const expected of oids) {
|
|
52
|
+
const lineEnd = output.indexOf(0x0a, offset);
|
|
53
|
+
if (lineEnd < 0) throw new Error("ownership inventory: malformed Git blob header");
|
|
54
|
+
const [oid, type, sizeText] = output.subarray(offset, lineEnd).toString("ascii").split(" ");
|
|
55
|
+
const size = Number(sizeText), start = lineEnd + 1, end = start + size;
|
|
56
|
+
if (oid !== expected || type !== "blob" || !Number.isSafeInteger(size) || size < 0 || size > MAX_BLOB_BATCH_BYTES || end >= output.length || output[end] !== 0x0a) throw new Error("ownership inventory: invalid Git blob");
|
|
57
|
+
result.set(expected, Buffer.from(output.subarray(start, end))); offset = end + 1;
|
|
58
|
+
}
|
|
59
|
+
if (offset !== output.length) throw new Error("ownership inventory: unexpected Git blob output");
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function gitListedInventory(appRoot, { relevant = () => true, protectedPaths = [], maxFileBytes = DEFAULT_MAX_FILE_BYTES, repositoryRoot = appRoot } = {}) {
|
|
64
|
+
const root = path.resolve(appRoot);
|
|
65
|
+
let safeRoot;
|
|
66
|
+
try { safeRoot = realpathSync(repositoryRoot); }
|
|
67
|
+
catch (error) { return { files: [], coverage: [{ path: ".", status: "unscanned", reason: `repository-root-unavailable:${error.code ?? "error"}` }] }; }
|
|
68
|
+
let listing, dirtyBytes;
|
|
69
|
+
try {
|
|
70
|
+
listing = runGit(root, safeRoot, ["ls-files", "--stage", "--cached", "--others", "--exclude-standard", "-z"]);
|
|
71
|
+
dirtyBytes = runGit(root, safeRoot, ["diff-files", "--name-only", "-z", "--no-ext-diff", "--no-textconv", "--relative"]);
|
|
72
|
+
} catch (error) { return { files: [], coverage: [{ path: ".", status: "unscanned", reason: `git-inventory-failed:${error.message}` }] }; }
|
|
73
|
+
const dirty = new Set(dirtyBytes.length ? dirtyBytes.subarray(0, dirtyBytes.length - (dirtyBytes.at(-1) === 0 ? 1 : 0)).toString("utf8").split("\0") : []);
|
|
74
|
+
const entries = new Map(), coverage = [];
|
|
75
|
+
try {
|
|
76
|
+
let offset = 0;
|
|
77
|
+
while (offset < listing.length) {
|
|
78
|
+
const end = listing.indexOf(0, offset); if (end < 0) throw new Error("malformed NUL listing");
|
|
79
|
+
const record = listing.subarray(offset, end); offset = end + 1;
|
|
80
|
+
const tab = record.indexOf(0x09);
|
|
81
|
+
const candidate = tab < 0 ? "" : record.subarray(0, tab).toString("ascii");
|
|
82
|
+
if (tab >= 0 && /^(?:100644|100755|120000|160000) [0-9a-f]{40,64} \d$/.test(candidate)) {
|
|
83
|
+
const header = record.subarray(0, tab).toString("ascii").split(" ");
|
|
84
|
+
const rel = decodePath(record.subarray(tab + 1));
|
|
85
|
+
if (header.length !== 3 || !/^(?:100644|100755|120000|160000)$/.test(header[0]) || !/^[0-9a-f]{40,64}$/.test(header[1]) || !/^\d$/.test(header[2])) throw new Error("malformed stage entry");
|
|
86
|
+
const entry = entries.get(rel) ?? { rel, stages: [] };
|
|
87
|
+
entry.stages.push({ mode: header[0], oid: header[1], stage: Number(header[2]) }); entries.set(rel, entry);
|
|
88
|
+
} else {
|
|
89
|
+
const rel = decodePath(record);
|
|
90
|
+
if (!entries.has(rel)) entries.set(rel, { rel, stages: [], untracked: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
coverage.push({ path: ".", status: "unscanned", reason: `git-listing-invalid:${error.message}` });
|
|
95
|
+
return { files: [], coverage };
|
|
96
|
+
}
|
|
97
|
+
const protectedSet = new Set(protectedPaths);
|
|
98
|
+
const relevantEntries = [...entries.values()].filter((entry) => {
|
|
99
|
+
if (entry.stages.some((item) => item.stage !== 0) || entry.stages.length > 1) return false;
|
|
100
|
+
let selected = false;
|
|
101
|
+
try { selected = protectedSet.has(entry.rel) || relevant(entry.rel); }
|
|
102
|
+
catch (error) { coverage.push({ path: entry.rel, status: "unscanned", reason: `relevance-check-failed:${error.message}` }); }
|
|
103
|
+
entry.relevant = selected;
|
|
104
|
+
if (selected) {
|
|
105
|
+
let sizeLimit;
|
|
106
|
+
try { sizeLimit = typeof maxFileBytes === "function" ? maxFileBytes(entry.rel) : maxFileBytes; }
|
|
107
|
+
catch (error) {
|
|
108
|
+
entry.overLimit = true;
|
|
109
|
+
coverage.push({ path: entry.rel, status: "unscanned", reason: `size-limit-check-failed:${error.message}` });
|
|
110
|
+
}
|
|
111
|
+
entry.maxFileBytes = Number.isSafeInteger(sizeLimit) && sizeLimit >= 0 ? sizeLimit : DEFAULT_MAX_FILE_BYTES;
|
|
112
|
+
try { entry.stat = lstatSync(path.join(root, ...entry.rel.split("/"))); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
entry.statError = error;
|
|
115
|
+
if (error.code === "ENOENT") coverage.push({ path: entry.rel, status: "unscanned", reason: "deleted" });
|
|
116
|
+
else coverage.push({ path: entry.rel, status: "unscanned", reason: `stat-failed:${error.code ?? "error"}` });
|
|
117
|
+
}
|
|
118
|
+
if (entry.stat?.isSymbolicLink()) {
|
|
119
|
+
entry.symlink = true; coverage.push({ path: entry.rel, status: "unscanned", reason: "symlink" });
|
|
120
|
+
} else if (entry.stat && !entry.stat.isFile()) {
|
|
121
|
+
entry.nonFile = true; coverage.push({ path: entry.rel, status: "unscanned", reason: "not-regular-file" });
|
|
122
|
+
}
|
|
123
|
+
if (entry.stat?.isFile() && dirty.has(entry.rel) && entry.stat.size > entry.maxFileBytes) {
|
|
124
|
+
entry.overLimit = true;
|
|
125
|
+
coverage.push({ path: entry.rel, status: "unscanned", reason: "file-over-size-limit" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return selected && !entry.statError && !entry.symlink && !entry.nonFile && !entry.overLimit && entry.stages[0]?.mode !== "120000" && entry.stages[0]?.mode !== "160000";
|
|
129
|
+
});
|
|
130
|
+
const blobOids = [...new Set(relevantEntries.filter((entry) => !entry.overLimit && entry.stages[0]?.oid && !dirty.has(entry.rel) && !blobCache.has(`gitblob:${entry.stages[0].oid}`)).map((entry) => entry.stages[0].oid))];
|
|
131
|
+
let fetched;
|
|
132
|
+
try { fetched = readGitBlobs(root, safeRoot, blobOids); }
|
|
133
|
+
catch (error) { return { files: [], coverage: [{ path: ".", status: "unscanned", reason: `git-blob-read-failed:${error.message}` }] }; }
|
|
134
|
+
for (const entry of relevantEntries) {
|
|
135
|
+
const oid = entry.stages[0]?.oid;
|
|
136
|
+
if (!oid || dirty.has(entry.rel) || entry.overLimit) continue;
|
|
137
|
+
const bytes = blobCache.get(`gitblob:${oid}`) ?? fetched.get(oid);
|
|
138
|
+
if (!bytes) continue;
|
|
139
|
+
entry.cleanBlobSize = bytes.length;
|
|
140
|
+
if (bytes.length > entry.maxFileBytes) {
|
|
141
|
+
entry.overLimit = true;
|
|
142
|
+
coverage.push({ path: entry.rel, status: "unscanned", reason: "file-over-size-limit" });
|
|
143
|
+
} else remember(oid, bytes);
|
|
144
|
+
}
|
|
145
|
+
const files = [];
|
|
146
|
+
for (const entry of entries.values()) {
|
|
147
|
+
const { rel } = entry, abs = path.join(root, ...rel.split("/"));
|
|
148
|
+
if (entry.stages.some((item) => item.stage !== 0) || entry.stages.length > 1) { coverage.push({ path: rel, status: "unscanned", reason: "git-index-conflict" }); continue; }
|
|
149
|
+
const indexed = entry.stages[0];
|
|
150
|
+
if (indexed?.mode === "160000") { coverage.push({ path: rel, status: "unscanned", reason: "gitlink" }); continue; }
|
|
151
|
+
const mode = indexed?.mode;
|
|
152
|
+
if (mode === "120000") { coverage.push({ path: rel, status: "unscanned", reason: "symlink" }); continue; }
|
|
153
|
+
const isRelevant = entry.relevant ?? false;
|
|
154
|
+
const isDirty = !indexed || dirty.has(rel);
|
|
155
|
+
const cleanIrrelevantRegular = !isRelevant && !isDirty && indexed?.oid && ["100644", "100755"].includes(mode);
|
|
156
|
+
let stat = entry.stat;
|
|
157
|
+
if (!cleanIrrelevantRegular) {
|
|
158
|
+
try { stat ??= lstatSync(abs); }
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === "ENOENT") coverage.push({ path: rel, status: "unscanned", reason: "deleted" });
|
|
161
|
+
else coverage.push({ path: rel, status: "unscanned", reason: `stat-failed:${error.code ?? "error"}` });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (stat.isSymbolicLink()) { coverage.push({ path: rel, status: "unscanned", reason: "symlink" }); continue; }
|
|
165
|
+
if (!stat.isFile()) { coverage.push({ path: rel, status: "unscanned", reason: "not-regular-file" }); continue; }
|
|
166
|
+
}
|
|
167
|
+
const inNodeModules = rel.split("/").includes("node_modules");
|
|
168
|
+
let bytes, oid = indexed?.oid, digest, cacheKey;
|
|
169
|
+
if (isRelevant && entry.overLimit) {
|
|
170
|
+
// Size-limit coverage was already recorded before any blob or file read.
|
|
171
|
+
} else if (isRelevant) {
|
|
172
|
+
try {
|
|
173
|
+
if (!isDirty && oid && blobCache.has(`gitblob:${oid}`)) bytes = blobCache.get(`gitblob:${oid}`);
|
|
174
|
+
else if (!isDirty && oid && fetched.has(oid)) bytes = fetched.get(oid);
|
|
175
|
+
else if (isDirty) {
|
|
176
|
+
if (stat.size > entry.maxFileBytes) {
|
|
177
|
+
coverage.push({ path: rel, status: "unscanned", reason: "file-over-size-limit" });
|
|
178
|
+
} else bytes = readFileSync(abs);
|
|
179
|
+
} else {
|
|
180
|
+
coverage.push({ path: rel, status: "unscanned", reason: "git-blob-cache-miss" });
|
|
181
|
+
}
|
|
182
|
+
} catch (error) { coverage.push({ path: rel, status: "unscanned", reason: `file-read-failed:${error.code ?? "error"}` }); continue; }
|
|
183
|
+
if (bytes && bytes.length > entry.maxFileBytes) {
|
|
184
|
+
coverage.push({ path: rel, status: "unscanned", reason: "file-over-size-limit" });
|
|
185
|
+
bytes = undefined;
|
|
186
|
+
}
|
|
187
|
+
if (bytes && isDirty) { digest = sha256(bytes); cacheKey = `sha256:${digest}`; }
|
|
188
|
+
else if (bytes) { digest = `git:${oid}`; cacheKey = `gitblob:${oid}`; }
|
|
189
|
+
} else if (!isDirty && oid) { digest = `git:${oid}`; cacheKey = `gitblob:${oid}`; }
|
|
190
|
+
files.push({ rel, abs, size: bytes?.length ?? entry.cleanBlobSize ?? stat?.size, bytes, oid, digest, cacheKey, inNodeModules });
|
|
191
|
+
}
|
|
192
|
+
return { files, coverage };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function clearGitListedInventoryCacheForTests() { blobCache.clear(); cacheBytes = 0; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"windows": {
|
|
4
|
+
"path": "C:\\Users\\adrds\\.rightkit-managed\\ownership-approval\\windows\\content-8d12e39ee6bac3bf8332df8dc61c74637e2eda6becbcc39746fe1ed5a1fe7ef6\\RightKit.OwnershipApproval.Windows.exe",
|
|
5
|
+
"sha256": "8d12e39ee6bac3bf8332df8dc61c74637e2eda6becbcc39746fe1ed5a1fe7ef6"
|
|
6
|
+
},
|
|
7
|
+
"macos": {
|
|
8
|
+
"path": "/Users/adrdsouza/.rightkit-managed/ownership-approval/macos/content-54224936edb74c9044dbaaa38a0773ee77acec21826237572b9b7c67a87b8ccc/ownership-approval",
|
|
9
|
+
"sha256": "54224936edb74c9044dbaaa38a0773ee77acec21826237572b9b7c67a87b8ccc"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { canonicalRepositoryIdentity, resolveKeyLogTrust } from "./ownership-approval.mjs";
|
|
6
|
+
|
|
7
|
+
const LOG_PATH = ".rightkit/ownership-keys.json";
|
|
8
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const executable = process.platform === "win32" ? "C:\\Program Files\\Git\\cmd\\git.exe" : "/usr/bin/git";
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
|
|
12
|
+
function gitEnv() {
|
|
13
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
|
|
14
|
+
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
15
|
+
env.GIT_CONFIG_GLOBAL = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
16
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
17
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(root, args, encoding = "utf8") {
|
|
22
|
+
const safeDirectory = process.platform === "win32" ? root.replaceAll("\\", "/") : root;
|
|
23
|
+
const result = spawnSync(executable, ["-C", root, "--no-replace-objects", "-c", `safe.directory=${safeDirectory}`, "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`, ...args], {
|
|
24
|
+
encoding, env: gitEnv(), windowsHide: true, maxBuffer: MAX_LOG_BYTES + 4096, timeout: 15_000,
|
|
25
|
+
});
|
|
26
|
+
if (result?.error || result?.status !== 0) throw new Error(`authority Git ${args[0]} failed`);
|
|
27
|
+
return result.stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateAuthority(authority) {
|
|
31
|
+
if (!authority || typeof authority !== "object" || Array.isArray(authority)
|
|
32
|
+
|| Object.keys(authority).sort().join("\0") !== ["checkoutPath", "repositoryIdentity"].sort().join("\0")) throw new Error("protected key authority fields are malformed");
|
|
33
|
+
if (typeof authority.checkoutPath !== "string" || !path.isAbsolute(authority.checkoutPath) || authority.checkoutPath.includes("\0")) throw new Error("protected key authority checkoutPath must be absolute");
|
|
34
|
+
const repositoryIdentity = canonicalRepositoryIdentity(authority.repositoryIdentity);
|
|
35
|
+
if (repositoryIdentity !== authority.repositoryIdentity) throw new Error("protected key authority identity must be canonical");
|
|
36
|
+
const checkoutPath = realpathSync(authority.checkoutPath);
|
|
37
|
+
if (!statSync(checkoutPath).isDirectory()) throw new Error("protected key authority checkoutPath must be a directory");
|
|
38
|
+
return { repositoryIdentity, checkoutPath };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read one exact key-log blob from the protected authority checkout's Git HEAD. */
|
|
42
|
+
export function readCommittedKeyAuthority(trust) {
|
|
43
|
+
try {
|
|
44
|
+
const expected = validateAuthority(trust?.keyAuthority);
|
|
45
|
+
const top = String(git(expected.checkoutPath, ["rev-parse", "--show-toplevel"])).trim();
|
|
46
|
+
const normalizedTop = process.platform === "win32" ? path.resolve(top).toLowerCase() : path.resolve(top);
|
|
47
|
+
const normalizedExpected = process.platform === "win32" ? expected.checkoutPath.toLowerCase() : expected.checkoutPath;
|
|
48
|
+
if (normalizedTop !== normalizedExpected) throw new Error("protected key authority checkoutPath must be exact Git repository root");
|
|
49
|
+
const origin = String(git(expected.checkoutPath, ["config", "--get", "remote.origin.url"])).trim();
|
|
50
|
+
if (canonicalRepositoryIdentity(origin) !== expected.repositoryIdentity) throw new Error("authority repository origin does not match protected identity");
|
|
51
|
+
const commit = String(git(expected.checkoutPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
|
|
52
|
+
const tree = String(git(expected.checkoutPath, ["rev-parse", "--verify", `${commit}^{tree}`])).trim();
|
|
53
|
+
const listing = git(expected.checkoutPath, ["ls-tree", "-z", commit, "--", LOG_PATH], "buffer");
|
|
54
|
+
const rows = listing.toString("utf8").split("\0").filter(Boolean);
|
|
55
|
+
if (rows.length !== 1) throw new Error("authority committed key log is missing or ambiguous");
|
|
56
|
+
const match = rows[0].match(/^(100644|100755) blob ([0-9a-f]{40}|[0-9a-f]{64})\t\.rightkit\/ownership-keys\.json$/);
|
|
57
|
+
if (!match) throw new Error("authority key log must be a regular committed blob, not a symlink or unsupported mode");
|
|
58
|
+
const oid = match[2];
|
|
59
|
+
const size = Number(String(git(expected.checkoutPath, ["cat-file", "-s", oid])).trim());
|
|
60
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_LOG_BYTES) throw new Error("authority key log exceeds 2 MiB or has invalid size");
|
|
61
|
+
const bytes = git(expected.checkoutPath, ["cat-file", "blob", oid], "buffer");
|
|
62
|
+
if (!Buffer.isBuffer(bytes) || bytes.length !== size) throw new Error("authority key log blob size changed during read");
|
|
63
|
+
const algorithm = oid.length === 64 ? "sha256" : "sha1";
|
|
64
|
+
const blobOid = createHash(algorithm).update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
65
|
+
if (blobOid !== oid) throw new Error("authority key log blob hash does not match committed tree");
|
|
66
|
+
const key = `${oid}:${trust.rootFingerprint}:${trust.keyCheckpoint?.sequence}:${trust.keyCheckpoint?.head}`;
|
|
67
|
+
let result = cache.get(key);
|
|
68
|
+
if (!result) {
|
|
69
|
+
const resolved = resolveKeyLogTrust(trust, bytes);
|
|
70
|
+
if (!resolved.ok) throw new Error(`authority key log verification failed: ${resolved.problems.join("; ")}`);
|
|
71
|
+
result = { bytes, digest: resolved.keyLogDigest, rootFingerprint: resolved.trust.rootFingerprint, keyCheckpoint: resolved.checkpoint };
|
|
72
|
+
cache.set(key, result);
|
|
73
|
+
if (cache.size > 64) cache.delete(cache.keys().next().value);
|
|
74
|
+
}
|
|
75
|
+
// Re-resolve on each call so current protected policy is always merged into
|
|
76
|
+
// the cached chain result; approval-policy changes never reuse stale trust.
|
|
77
|
+
const resolved = resolveKeyLogTrust(trust, result.bytes);
|
|
78
|
+
if (!resolved.ok) throw new Error(`authority key log verification failed: ${resolved.problems.join("; ")}`);
|
|
79
|
+
return { ok: true, problems: [], bytes: Buffer.from(result.bytes), digest: result.digest, rootFingerprint: result.rootFingerprint, keyCheckpoint: result.keyCheckpoint, resolvedTrust: resolved.trust, authority: { repositoryIdentity: expected.repositoryIdentity, commit, tree } };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { ok: false, problems: [error?.message ?? "protected key authority could not be read"] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const __testOnly = Object.freeze({ MAX_LOG_BYTES });
|