forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { StringDecoder } = require('node:string_decoder');
|
|
8
|
+
const { selectBuiltinSQLiteRuntime } = require('./kernel/sqlite-driver');
|
|
9
|
+
|
|
10
|
+
const BETA5_VERSION = '0.1.0-beta.5';
|
|
11
|
+
const BETA5_RELEASE_COMMIT = 'ebeb4e5b31fc2dacdf23c5936c02fb2656990f49';
|
|
12
|
+
const BETA5_CORPUS_ROOT = path.join(__dirname, 'fixtures', 'beta5-corpus', 'v1');
|
|
13
|
+
const KERNEL_COUNT_TABLES = Object.freeze({
|
|
14
|
+
issues: 'kernel_issues',
|
|
15
|
+
comments: 'kernel_comments',
|
|
16
|
+
dependencies: 'kernel_dependencies',
|
|
17
|
+
claims: 'kernel_claims',
|
|
18
|
+
runs: 'kernel_stage_runs',
|
|
19
|
+
projections: 'kernel_projections',
|
|
20
|
+
worktrees: 'kernel_worktrees',
|
|
21
|
+
events: 'kernel_events',
|
|
22
|
+
});
|
|
23
|
+
const REQUIRED_KERNEL_TABLES = Object.freeze(['kernel_migrations', ...Object.values(KERNEL_COUNT_TABLES)]);
|
|
24
|
+
const DEFAULT_EVIDENCE_LIMITS = Object.freeze({
|
|
25
|
+
maxDepth: 12,
|
|
26
|
+
maxFiles: 4096,
|
|
27
|
+
maxBytes: 256 * 1024 * 1024,
|
|
28
|
+
maxFileBytes: 64 * 1024 * 1024,
|
|
29
|
+
maxJsonlRows: 100000,
|
|
30
|
+
maxSqliteRows: 100000,
|
|
31
|
+
maxSqliteTables: 128,
|
|
32
|
+
maxSqliteContentBytes: 128 * 1024 * 1024,
|
|
33
|
+
maxManifestBytes: 1024 * 1024,
|
|
34
|
+
maxManifestEntries: 4096,
|
|
35
|
+
maxDurationMs: 30000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
class IncompleteEvidenceError extends Error {
|
|
39
|
+
constructor(reason) {
|
|
40
|
+
super(reason);
|
|
41
|
+
this.reason = reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class SecurityEvidenceError extends Error {
|
|
46
|
+
constructor(reason) {
|
|
47
|
+
super(reason);
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveLimits(overrides = {}) {
|
|
53
|
+
return Object.fromEntries(Object.entries(DEFAULT_EVIDENCE_LIMITS).map(([key, fallback]) => {
|
|
54
|
+
const candidate = overrides[key];
|
|
55
|
+
return [key, Number.isFinite(candidate) && candidate >= 0 ? candidate : fallback];
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createBudget(options = {}) {
|
|
60
|
+
const limits = resolveLimits(options.limits);
|
|
61
|
+
const now = options.now || Date.now;
|
|
62
|
+
const deadline = now() + limits.maxDurationMs;
|
|
63
|
+
return {
|
|
64
|
+
limits,
|
|
65
|
+
check() {
|
|
66
|
+
if (now() > deadline) throw new IncompleteEvidenceError('evidence_time_limit');
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function noFollowFlag() {
|
|
72
|
+
return process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readOnlyOpenFlags() {
|
|
76
|
+
return process.platform === 'win32' ? 'r' : fs.constants.O_RDONLY | noFollowFlag();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function exclusiveWriteOpenFlags() {
|
|
80
|
+
return process.platform === 'win32'
|
|
81
|
+
? 'wx'
|
|
82
|
+
: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sha256(bytes) {
|
|
86
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compareCanonicalText(left, right) {
|
|
90
|
+
return Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkRegularFile(filePath, budget, sizeReason = 'state_tree_file_size_limit') {
|
|
94
|
+
budget.check();
|
|
95
|
+
const stat = fs.lstatSync(filePath);
|
|
96
|
+
if (stat.isSymbolicLink()) throw new SecurityEvidenceError('source_link_refused');
|
|
97
|
+
if (!stat.isFile()) throw new IncompleteEvidenceError('state_tree_unsupported_entry');
|
|
98
|
+
if (stat.size > budget.limits.maxFileBytes) throw new IncompleteEvidenceError(sizeReason);
|
|
99
|
+
return stat;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hashFileBounded(filePath, budget, sizeReason = 'state_tree_file_size_limit') {
|
|
103
|
+
const stat = checkRegularFile(filePath, budget, sizeReason);
|
|
104
|
+
const descriptor = fs.openSync(filePath, readOnlyOpenFlags());
|
|
105
|
+
const hash = crypto.createHash('sha256');
|
|
106
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
107
|
+
let total = 0;
|
|
108
|
+
try {
|
|
109
|
+
const opened = fs.fstatSync(descriptor);
|
|
110
|
+
if (!opened.isFile() || !sameFileIdentity(stat, opened)) {
|
|
111
|
+
throw new SecurityEvidenceError('source_changed_during_read');
|
|
112
|
+
}
|
|
113
|
+
for (;;) {
|
|
114
|
+
budget.check();
|
|
115
|
+
const read = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
116
|
+
if (read === 0) break;
|
|
117
|
+
total += read;
|
|
118
|
+
if (total > budget.limits.maxFileBytes) throw new IncompleteEvidenceError(sizeReason);
|
|
119
|
+
hash.update(buffer.subarray(0, read));
|
|
120
|
+
}
|
|
121
|
+
if (total !== opened.size) throw new SecurityEvidenceError('source_changed_during_read');
|
|
122
|
+
return hash.digest('hex');
|
|
123
|
+
} finally {
|
|
124
|
+
fs.closeSync(descriptor);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function categoryFor(relativePath) {
|
|
129
|
+
const normalized = relativePath.replaceAll('\\', '/');
|
|
130
|
+
if (normalized === 'package.json') return 'package';
|
|
131
|
+
if (normalized === '.forge/kernel.sqlite') return 'kernel';
|
|
132
|
+
if (normalized === '.forge/config.yaml' || normalized === '.forge/project.json') return 'config';
|
|
133
|
+
if (normalized === '.forge/issues.jsonl') return 'issueProjection';
|
|
134
|
+
if (normalized === '.forge/comments.jsonl') return 'commentProjection';
|
|
135
|
+
if (normalized === '.forge/dependencies.jsonl') return 'dependencyProjection';
|
|
136
|
+
if (normalized === '.forge/worktrees.jsonl') return 'worktreeProjection';
|
|
137
|
+
if (normalized.startsWith('.forge/runs/')) return 'runEvidence';
|
|
138
|
+
if (normalized.startsWith('.forge/projections/')) return 'projection';
|
|
139
|
+
return 'other';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function summarizeCategories(files) {
|
|
143
|
+
const counts = {};
|
|
144
|
+
for (const file of files) counts[file.category] = (counts[file.category] || 0) + 1;
|
|
145
|
+
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => compareCanonicalText(left, right)));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isSafeRelativePath(relativePath) {
|
|
149
|
+
return typeof relativePath === 'string'
|
|
150
|
+
&& relativePath.length > 0
|
|
151
|
+
&& !path.isAbsolute(relativePath)
|
|
152
|
+
&& !relativePath.split(/[\\/]/).includes('..');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function aggregateHash(files) {
|
|
156
|
+
const sortedFiles = [...files];
|
|
157
|
+
sortedFiles.sort((left, right) => compareCanonicalText(left.path, right.path));
|
|
158
|
+
const canonical = sortedFiles
|
|
159
|
+
.map(file => `${file.path}\0${file.sha256}\n`)
|
|
160
|
+
.join('');
|
|
161
|
+
return sha256(canonical);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readFileFullyBounded(filePath, budget, maxBytes, reason) {
|
|
165
|
+
const stat = checkRegularFile(filePath, budget, 'corpus_file_size_limit');
|
|
166
|
+
if (stat.size > maxBytes) throw new IncompleteEvidenceError(reason);
|
|
167
|
+
const descriptor = fs.openSync(filePath, readOnlyOpenFlags());
|
|
168
|
+
const bytes = Buffer.alloc(stat.size);
|
|
169
|
+
try {
|
|
170
|
+
const opened = fs.fstatSync(descriptor);
|
|
171
|
+
if (!sameFileIdentity(stat, opened)) throw new SecurityEvidenceError('corpus_file_changed');
|
|
172
|
+
let offset = 0;
|
|
173
|
+
while (offset < bytes.length) {
|
|
174
|
+
budget.check();
|
|
175
|
+
const read = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
176
|
+
if (read === 0) break;
|
|
177
|
+
offset += read;
|
|
178
|
+
}
|
|
179
|
+
if (offset !== bytes.length) throw new SecurityEvidenceError('corpus_file_changed');
|
|
180
|
+
return bytes;
|
|
181
|
+
} finally {
|
|
182
|
+
fs.closeSync(descriptor);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scanCorpusEntry(rootIdentity, absolutePath, relativePath, depth, files, totals, budget) {
|
|
187
|
+
budget.check();
|
|
188
|
+
if (depth > budget.limits.maxDepth) throw new IncompleteEvidenceError('corpus_depth_limit');
|
|
189
|
+
const stat = fs.lstatSync(absolutePath);
|
|
190
|
+
if (stat.isSymbolicLink()) throw new SecurityEvidenceError('corpus_link_refused');
|
|
191
|
+
const identity = fs.realpathSync.native(absolutePath);
|
|
192
|
+
if (!isContained(rootIdentity, identity)) throw new SecurityEvidenceError('corpus_path_escape');
|
|
193
|
+
if (stat.isDirectory()) {
|
|
194
|
+
const directory = fs.opendirSync(absolutePath);
|
|
195
|
+
try {
|
|
196
|
+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
|
|
197
|
+
totals.entries += 1;
|
|
198
|
+
if (totals.entries > budget.limits.maxFiles) throw new IncompleteEvidenceError('corpus_file_limit');
|
|
199
|
+
scanCorpusEntry(
|
|
200
|
+
rootIdentity,
|
|
201
|
+
path.join(absolutePath, entry.name),
|
|
202
|
+
path.join(relativePath, entry.name),
|
|
203
|
+
depth + 1,
|
|
204
|
+
files,
|
|
205
|
+
totals,
|
|
206
|
+
budget,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
directory.closeSync();
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!stat.isFile()) throw new SecurityEvidenceError('corpus_entry_unsupported');
|
|
215
|
+
if (stat.size > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('corpus_file_size_limit');
|
|
216
|
+
totals.files += 1;
|
|
217
|
+
totals.bytes += stat.size;
|
|
218
|
+
if (totals.files > budget.limits.maxFiles) throw new IncompleteEvidenceError('corpus_file_limit');
|
|
219
|
+
if (totals.bytes > budget.limits.maxBytes) throw new IncompleteEvidenceError('corpus_byte_limit');
|
|
220
|
+
files.push(relativePath.replaceAll('\\', '/'));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function scanCorpusTree(corpusRoot, budget) {
|
|
224
|
+
const rootStat = fs.lstatSync(corpusRoot);
|
|
225
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new SecurityEvidenceError('corpus_root_invalid');
|
|
226
|
+
const rootIdentity = fs.realpathSync.native(corpusRoot);
|
|
227
|
+
const files = [];
|
|
228
|
+
const totals = { entries: 0, files: 0, bytes: 0 };
|
|
229
|
+
const directory = fs.opendirSync(corpusRoot);
|
|
230
|
+
try {
|
|
231
|
+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
|
|
232
|
+
totals.entries += 1;
|
|
233
|
+
if (totals.entries > budget.limits.maxFiles) throw new IncompleteEvidenceError('corpus_file_limit');
|
|
234
|
+
scanCorpusEntry(rootIdentity, path.join(corpusRoot, entry.name), entry.name, 1, files, totals, budget);
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
directory.closeSync();
|
|
238
|
+
}
|
|
239
|
+
files.sort(compareCanonicalText);
|
|
240
|
+
return { files, rootIdentity };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function validateCorpusMetadata(manifest) {
|
|
244
|
+
const reasons = [];
|
|
245
|
+
if (manifest.schema_version !== 'forge.beta5-corpus.v1') reasons.push('schema_version_mismatch');
|
|
246
|
+
if (manifest.release_version !== BETA5_VERSION) reasons.push('release_version_mismatch');
|
|
247
|
+
if (manifest.release_commit !== BETA5_RELEASE_COMMIT) reasons.push('release_commit_mismatch');
|
|
248
|
+
if (!Array.isArray(manifest.files) || manifest.files.length === 0) reasons.push('file_manifest_empty');
|
|
249
|
+
const sourceBlobs = manifest.source_blobs && typeof manifest.source_blobs === 'object'
|
|
250
|
+
? Object.entries(manifest.source_blobs)
|
|
251
|
+
: [];
|
|
252
|
+
const invalidSource = sourceBlobs.some(([source, blob]) => !isSafeRelativePath(source)
|
|
253
|
+
|| !/^[a-f0-9]{40}$/.test(blob));
|
|
254
|
+
if (sourceBlobs.length !== 4 || invalidSource) reasons.push('source_provenance_invalid');
|
|
255
|
+
return { reasons, sourceBlobs };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function observeCorpusEntry(corpusRoot, rootIdentity, entry, budget) {
|
|
259
|
+
if (!entry || typeof entry !== 'object' || !isSafeRelativePath(entry.path)
|
|
260
|
+
|| !/^[a-f0-9]{64}$/.test(entry.sha256 || '')) {
|
|
261
|
+
return { reason: 'corpus_entry_invalid' };
|
|
262
|
+
}
|
|
263
|
+
const lexicalRoot = path.resolve(corpusRoot);
|
|
264
|
+
const filePath = path.resolve(lexicalRoot, entry.path);
|
|
265
|
+
const relative = path.relative(lexicalRoot, filePath);
|
|
266
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) {
|
|
267
|
+
return { reason: 'corpus_path_escape' };
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const stat = fs.lstatSync(filePath);
|
|
271
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { reason: 'corpus_entry_not_regular' };
|
|
272
|
+
const identity = fs.realpathSync.native(filePath);
|
|
273
|
+
if (!isContained(rootIdentity, identity)) return { reason: 'corpus_path_escape' };
|
|
274
|
+
const observed = { path: entry.path, sha256: hashFileBounded(filePath, budget, 'corpus_file_size_limit') };
|
|
275
|
+
return observed.sha256 === entry.sha256
|
|
276
|
+
? { observed }
|
|
277
|
+
: { observed, reason: 'corpus_hash_mismatch' };
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof IncompleteEvidenceError || error instanceof SecurityEvidenceError) throw error;
|
|
280
|
+
return { reason: 'corpus_file_missing' };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function findUnmanifestedFiles(corpusFiles, manifestFiles) {
|
|
285
|
+
const declared = new Set(manifestFiles.map(entry => entry.path));
|
|
286
|
+
return corpusFiles.some(relativePath => relativePath !== 'manifest.json' && !declared.has(relativePath))
|
|
287
|
+
? ['unmanifested_file']
|
|
288
|
+
: [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function corpusLimitResult(error) {
|
|
292
|
+
return {
|
|
293
|
+
status: error instanceof IncompleteEvidenceError ? 'INCOMPLETE' : 'FAIL',
|
|
294
|
+
reasons: [error.reason || 'corpus_inventory_unreadable'],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Verify the frozen beta.5 corpus and its declared provenance without mutating it. */
|
|
299
|
+
function verifyBeta5Corpus(corpusRoot = BETA5_CORPUS_ROOT, options = {}) {
|
|
300
|
+
const budget = createBudget(options);
|
|
301
|
+
let manifest;
|
|
302
|
+
let corpus;
|
|
303
|
+
try {
|
|
304
|
+
const manifestBytes = readFileFullyBounded(
|
|
305
|
+
path.join(corpusRoot, 'manifest.json'),
|
|
306
|
+
budget,
|
|
307
|
+
budget.limits.maxManifestBytes,
|
|
308
|
+
'corpus_manifest_byte_limit',
|
|
309
|
+
);
|
|
310
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
311
|
+
if (Array.isArray(manifest.files) && manifest.files.length > budget.limits.maxManifestEntries) {
|
|
312
|
+
throw new IncompleteEvidenceError('corpus_manifest_entry_limit');
|
|
313
|
+
}
|
|
314
|
+
corpus = scanCorpusTree(corpusRoot, budget);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error instanceof IncompleteEvidenceError || error instanceof SecurityEvidenceError) {
|
|
317
|
+
return corpusLimitResult(error);
|
|
318
|
+
}
|
|
319
|
+
return { status: 'FAIL', reasons: ['manifest_unreadable'] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const manifestFiles = Array.isArray(manifest.files) ? manifest.files : [];
|
|
323
|
+
const metadata = validateCorpusMetadata(manifest);
|
|
324
|
+
let observations;
|
|
325
|
+
try {
|
|
326
|
+
observations = manifestFiles.map(entry => observeCorpusEntry(corpusRoot, corpus.rootIdentity, entry, budget));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return corpusLimitResult(error);
|
|
329
|
+
}
|
|
330
|
+
const observedFiles = observations.flatMap(result => result.observed ? [result.observed] : []);
|
|
331
|
+
const reasons = [
|
|
332
|
+
...metadata.reasons,
|
|
333
|
+
...observations.flatMap(result => result.reason ? [result.reason] : []),
|
|
334
|
+
...findUnmanifestedFiles(corpus.files, manifestFiles),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const observedContentHash = aggregateHash(observedFiles);
|
|
338
|
+
if (observedFiles.length === manifest.files?.length && observedContentHash !== manifest.content_hash) {
|
|
339
|
+
reasons.push('content_hash_mismatch');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
status: reasons.length === 0 ? 'PASS' : 'FAIL',
|
|
344
|
+
version: manifest.release_version || null,
|
|
345
|
+
releaseCommit: manifest.release_commit || null,
|
|
346
|
+
corpusVersion: manifest.corpus_version || null,
|
|
347
|
+
fileCount: observedFiles.length,
|
|
348
|
+
sourceBlobCount: metadata.sourceBlobs.length,
|
|
349
|
+
contentHash: observedContentHash,
|
|
350
|
+
reasons: [...new Set(reasons)].toSorted(compareCanonicalText),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function countJsonlRecords(filePath, budget) {
|
|
355
|
+
const descriptor = fs.openSync(filePath, readOnlyOpenFlags());
|
|
356
|
+
const decoder = new StringDecoder('utf8');
|
|
357
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
358
|
+
let pending = '';
|
|
359
|
+
let records = 0;
|
|
360
|
+
let bytes = 0;
|
|
361
|
+
const consume = (line) => {
|
|
362
|
+
if (!line.trim()) return;
|
|
363
|
+
records += 1;
|
|
364
|
+
if (records > budget.limits.maxJsonlRows) throw new IncompleteEvidenceError('issue_row_limit');
|
|
365
|
+
JSON.parse(line);
|
|
366
|
+
};
|
|
367
|
+
try {
|
|
368
|
+
for (;;) {
|
|
369
|
+
budget.check();
|
|
370
|
+
const read = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
371
|
+
if (read === 0) break;
|
|
372
|
+
bytes += read;
|
|
373
|
+
if (bytes > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('issue_file_size_limit');
|
|
374
|
+
pending += decoder.write(buffer.subarray(0, read));
|
|
375
|
+
const lines = pending.split(/\r?\n/);
|
|
376
|
+
pending = lines.pop() || '';
|
|
377
|
+
lines.forEach(consume);
|
|
378
|
+
}
|
|
379
|
+
consume(pending + decoder.end());
|
|
380
|
+
return records;
|
|
381
|
+
} finally {
|
|
382
|
+
fs.closeSync(descriptor);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function inspectJsonl(filePath, options = {}) {
|
|
387
|
+
if (!fs.existsSync(filePath)) return { status: 'PASS', present: false, records: 0 };
|
|
388
|
+
const budget = options.budget || createBudget(options);
|
|
389
|
+
try {
|
|
390
|
+
checkRegularFile(filePath, budget, 'issue_file_size_limit');
|
|
391
|
+
const records = countJsonlRecords(filePath, budget);
|
|
392
|
+
return { status: 'PASS', present: true, records };
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return { status: 'INCOMPLETE', present: true, reason: error.reason || 'unreadable_or_malformed' };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function inspectPackage(filePath, options = {}) {
|
|
399
|
+
if (!fs.existsSync(filePath)) return { status: 'PASS', present: false, beta5Declared: false };
|
|
400
|
+
const budget = options.budget || createBudget(options);
|
|
401
|
+
try {
|
|
402
|
+
const stat = checkRegularFile(filePath, budget, 'package_file_size_limit');
|
|
403
|
+
const descriptor = fs.openSync(filePath, readOnlyOpenFlags());
|
|
404
|
+
const bytes = Buffer.alloc(stat.size);
|
|
405
|
+
try {
|
|
406
|
+
let offset = 0;
|
|
407
|
+
while (offset < bytes.length) {
|
|
408
|
+
budget.check();
|
|
409
|
+
const read = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
410
|
+
if (read === 0) break;
|
|
411
|
+
offset += read;
|
|
412
|
+
}
|
|
413
|
+
if (offset !== bytes.length) throw new SecurityEvidenceError('source_changed_during_read');
|
|
414
|
+
} finally {
|
|
415
|
+
fs.closeSync(descriptor);
|
|
416
|
+
}
|
|
417
|
+
const value = JSON.parse(bytes.toString('utf8'));
|
|
418
|
+
const declared = [value.version, value.dependencies?.['forge-workflow'], value.devDependencies?.['forge-workflow']]
|
|
419
|
+
.filter(Boolean);
|
|
420
|
+
return { status: 'PASS', present: true, beta5Declared: declared.includes(BETA5_VERSION) };
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return { status: 'INCOMPLETE', present: true, reason: error.reason || 'unreadable_or_malformed' };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function inspectPresence(filePath) {
|
|
427
|
+
if (!fs.existsSync(filePath)) return { status: 'PASS', present: false };
|
|
428
|
+
try {
|
|
429
|
+
const stat = fs.lstatSync(filePath);
|
|
430
|
+
return stat.isFile() && !stat.isSymbolicLink()
|
|
431
|
+
? { status: 'PASS', present: true, bytes: stat.size }
|
|
432
|
+
: { status: 'INCOMPLETE', present: true, reason: 'not_regular_file' };
|
|
433
|
+
} catch (_error) { // NOSONAR S2486 -- unreadable input is intentionally reported as incomplete.
|
|
434
|
+
return { status: 'INCOMPLETE', present: true, reason: 'unreadable' };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function openReadOnlyDatabase(runtime, databasePath) {
|
|
439
|
+
if (runtime.id === 'bun:sqlite') {
|
|
440
|
+
return new runtime.module.Database(databasePath, { readonly: true, create: false, strict: true });
|
|
441
|
+
}
|
|
442
|
+
if (runtime.id === 'node:sqlite') {
|
|
443
|
+
return new runtime.module.DatabaseSync(databasePath, { readOnly: true });
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function queryAll(runtime, database, sql) {
|
|
449
|
+
const statement = database.prepare(sql);
|
|
450
|
+
try {
|
|
451
|
+
return statement.all();
|
|
452
|
+
} finally {
|
|
453
|
+
if (typeof statement.finalize === 'function') statement.finalize();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function queryOne(runtime, database, sql) {
|
|
458
|
+
return queryAll(runtime, database, sql)[0] || {};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function closeDatabase(database) {
|
|
462
|
+
if (database && typeof database.close === 'function') database.close();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function hasSqliteHeader(filePath) {
|
|
466
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
467
|
+
const header = Buffer.alloc(16);
|
|
468
|
+
try {
|
|
469
|
+
fs.readSync(descriptor, header, 0, header.length, 0);
|
|
470
|
+
} finally {
|
|
471
|
+
fs.closeSync(descriptor);
|
|
472
|
+
}
|
|
473
|
+
return header.toString('utf8') === 'SQLite format 3\0';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function countKernelRows(runtime, database) {
|
|
477
|
+
return Object.fromEntries(Object.entries(KERNEL_COUNT_TABLES).map(([label, table]) => [
|
|
478
|
+
label,
|
|
479
|
+
Number(queryOne(runtime, database, `SELECT COUNT(*) AS count FROM "${table}";`).count || 0),
|
|
480
|
+
]));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function inspectKernel(filePath, options = {}) {
|
|
484
|
+
if (!fs.existsSync(filePath)) return { status: 'PASS', present: false, unknowns: [] };
|
|
485
|
+
const budget = options.budget || createBudget(options);
|
|
486
|
+
let database;
|
|
487
|
+
try {
|
|
488
|
+
checkRegularFile(filePath, budget, 'kernel_file_size_limit');
|
|
489
|
+
if (!hasSqliteHeader(filePath)) {
|
|
490
|
+
return { status: 'INCOMPLETE', present: true, reason: 'unrecognized_database', unknowns: ['kernel_schema'] };
|
|
491
|
+
}
|
|
492
|
+
const runtime = selectBuiltinSQLiteRuntime();
|
|
493
|
+
database = openReadOnlyDatabase(runtime, filePath);
|
|
494
|
+
const tableNames = new Set(queryAll(
|
|
495
|
+
runtime,
|
|
496
|
+
database,
|
|
497
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name LIMIT ${budget.limits.maxSqliteTables + 1};`,
|
|
498
|
+
).map(row => String(row.name)));
|
|
499
|
+
if (tableNames.size > budget.limits.maxSqliteTables) {
|
|
500
|
+
return { status: 'INCOMPLETE', present: true, reason: 'kernel_table_limit', unknowns: ['kernel_table_limit'] };
|
|
501
|
+
}
|
|
502
|
+
const missingTables = REQUIRED_KERNEL_TABLES.filter(table => !tableNames.has(table));
|
|
503
|
+
const integrity = String(queryOne(runtime, database, 'PRAGMA quick_check;').quick_check || 'unknown');
|
|
504
|
+
const counts = missingTables.length === 0 ? countKernelRows(runtime, database) : null;
|
|
505
|
+
if (counts && Object.values(counts).some(count => count > budget.limits.maxSqliteRows)) {
|
|
506
|
+
return { status: 'INCOMPLETE', present: true, reason: 'kernel_row_limit', unknowns: ['kernel_row_limit'] };
|
|
507
|
+
}
|
|
508
|
+
const migrationCount = tableNames.has('kernel_migrations')
|
|
509
|
+
? Number(queryOne(runtime, database, 'SELECT COUNT(*) AS count FROM kernel_migrations;').count || 0)
|
|
510
|
+
: 0;
|
|
511
|
+
if (migrationCount > budget.limits.maxSqliteRows) {
|
|
512
|
+
return { status: 'INCOMPLETE', present: true, reason: 'kernel_row_limit', unknowns: ['kernel_row_limit'] };
|
|
513
|
+
}
|
|
514
|
+
const migrationIds = migrationCount > 0
|
|
515
|
+
? queryAll(runtime, database, `SELECT id FROM kernel_migrations ORDER BY id LIMIT ${budget.limits.maxSqliteRows + 1};`)
|
|
516
|
+
.map(row => String(row.id))
|
|
517
|
+
: [];
|
|
518
|
+
const unknowns = [];
|
|
519
|
+
if (missingTables.length > 0) unknowns.push('missing_required_tables');
|
|
520
|
+
if (integrity !== 'ok') unknowns.push('integrity_check');
|
|
521
|
+
if (migrationIds.length === 0) unknowns.push('migration_version');
|
|
522
|
+
return {
|
|
523
|
+
status: unknowns.length === 0 ? 'PASS' : 'INCOMPLETE',
|
|
524
|
+
present: true,
|
|
525
|
+
schemaVersion: migrationIds.at(-1) || null,
|
|
526
|
+
migrationCount,
|
|
527
|
+
counts,
|
|
528
|
+
additionalTableCount: Math.max(0, tableNames.size - (REQUIRED_KERNEL_TABLES.length - missingTables.length)),
|
|
529
|
+
unknowns,
|
|
530
|
+
};
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return {
|
|
533
|
+
status: 'INCOMPLETE',
|
|
534
|
+
present: true,
|
|
535
|
+
reason: error.reason || 'unreadable_or_unsupported',
|
|
536
|
+
unknowns: ['kernel_schema'],
|
|
537
|
+
};
|
|
538
|
+
} finally {
|
|
539
|
+
closeDatabase(database);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Inventory supported beta.5 state surfaces without returning user-controlled values. */
|
|
544
|
+
function buildPrivacySafeInventory(projectRoot, options = {}) {
|
|
545
|
+
const budget = createBudget(options);
|
|
546
|
+
const surfaces = {
|
|
547
|
+
package: inspectPackage(path.join(projectRoot, 'package.json'), { budget }),
|
|
548
|
+
config: inspectPresence(path.join(projectRoot, '.forge', 'config.yaml')),
|
|
549
|
+
project: inspectPresence(path.join(projectRoot, '.forge', 'project.json')),
|
|
550
|
+
issues: inspectJsonl(path.join(projectRoot, '.forge', 'issues.jsonl'), { budget }),
|
|
551
|
+
kernel: inspectKernel(path.join(projectRoot, '.forge', 'kernel.sqlite'), { budget }),
|
|
552
|
+
};
|
|
553
|
+
const unknowns = [];
|
|
554
|
+
if (surfaces.package.status === 'INCOMPLETE') unknowns.push('package_contract');
|
|
555
|
+
if (surfaces.package.status === 'PASS' && !surfaces.package.beta5Declared) unknowns.push('beta5_install_contract');
|
|
556
|
+
if (surfaces.config.status === 'INCOMPLETE') unknowns.push('config_shape');
|
|
557
|
+
if (surfaces.project.status === 'INCOMPLETE') unknowns.push('project_marker');
|
|
558
|
+
if (surfaces.issues.status === 'INCOMPLETE') unknowns.push('issue_records');
|
|
559
|
+
if (surfaces.issues.reason === 'issue_row_limit') unknowns.push('issue_row_limit');
|
|
560
|
+
if (surfaces.kernel.status === 'INCOMPLETE') unknowns.push(...surfaces.kernel.unknowns);
|
|
561
|
+
return {
|
|
562
|
+
schemaVersion: 'forge.beta5-inventory.v1',
|
|
563
|
+
status: unknowns.length === 0 ? 'PASS' : 'INCOMPLETE',
|
|
564
|
+
surfaces,
|
|
565
|
+
unknowns: [...new Set(unknowns)].toSorted(compareCanonicalText),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function isContained(rootIdentity, candidateIdentity) {
|
|
570
|
+
const relative = path.relative(rootIdentity, candidateIdentity);
|
|
571
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function recordStateEntry(rootIdentity, absolutePath, relativePath, depth, files, totals, budget) {
|
|
575
|
+
budget.check();
|
|
576
|
+
if (depth > budget.limits.maxDepth) throw new IncompleteEvidenceError('state_tree_depth_limit');
|
|
577
|
+
const stat = fs.lstatSync(absolutePath);
|
|
578
|
+
if (stat.isSymbolicLink()) throw new IncompleteEvidenceError('state_tree_contains_link');
|
|
579
|
+
const identity = fs.realpathSync.native(absolutePath);
|
|
580
|
+
if (!isContained(rootIdentity, identity)) throw new IncompleteEvidenceError('state_tree_outside_root');
|
|
581
|
+
if (stat.isDirectory()) {
|
|
582
|
+
for (const entry of fs.readdirSync(absolutePath).sort()) {
|
|
583
|
+
recordStateEntry(
|
|
584
|
+
rootIdentity,
|
|
585
|
+
path.join(absolutePath, entry),
|
|
586
|
+
path.join(relativePath, entry),
|
|
587
|
+
depth + 1,
|
|
588
|
+
files,
|
|
589
|
+
totals,
|
|
590
|
+
budget,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (!stat.isFile()) throw new IncompleteEvidenceError('state_tree_unsupported_entry');
|
|
596
|
+
if (stat.size > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('state_tree_file_size_limit');
|
|
597
|
+
totals.files += 1;
|
|
598
|
+
totals.bytes += stat.size;
|
|
599
|
+
if (totals.files > budget.limits.maxFiles) throw new IncompleteEvidenceError('state_tree_file_limit');
|
|
600
|
+
if (totals.bytes > budget.limits.maxBytes) throw new IncompleteEvidenceError('state_tree_byte_limit');
|
|
601
|
+
const normalizedPath = relativePath.replaceAll('\\', '/');
|
|
602
|
+
files.push({ path: normalizedPath, category: categoryFor(normalizedPath), sha256: hashFileBounded(absolutePath, budget) });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function scanStateTree(projectRoot, options = {}) {
|
|
606
|
+
const reasons = [];
|
|
607
|
+
const files = [];
|
|
608
|
+
const budget = options.budget || createBudget(options);
|
|
609
|
+
try {
|
|
610
|
+
const rootStat = fs.lstatSync(projectRoot);
|
|
611
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
612
|
+
throw new IncompleteEvidenceError('state_root_not_directory');
|
|
613
|
+
}
|
|
614
|
+
const rootIdentity = fs.realpathSync.native(projectRoot);
|
|
615
|
+
const totals = { files: 0, bytes: 0 };
|
|
616
|
+
for (const relativePath of ['.forge', 'package.json']) {
|
|
617
|
+
const absolutePath = path.join(projectRoot, relativePath);
|
|
618
|
+
if (fs.existsSync(absolutePath)) {
|
|
619
|
+
recordStateEntry(rootIdentity, absolutePath, relativePath, 1, files, totals, budget);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} catch (error) {
|
|
623
|
+
reasons.push(error.reason || 'state_tree_unreadable');
|
|
624
|
+
}
|
|
625
|
+
files.sort((left, right) => compareCanonicalText(left.path, right.path));
|
|
626
|
+
return {
|
|
627
|
+
status: reasons.length === 0 ? 'PASS' : 'INCOMPLETE',
|
|
628
|
+
fileCount: files.length,
|
|
629
|
+
hash: aggregateHash(files),
|
|
630
|
+
categories: summarizeCategories(files),
|
|
631
|
+
files,
|
|
632
|
+
reasons: [...new Set(reasons)].toSorted(compareCanonicalText),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Build a bounded content manifest for the supported project state tree. */
|
|
637
|
+
function buildStateTreeManifest(projectRoot, options = {}) {
|
|
638
|
+
const tree = scanStateTree(projectRoot, options);
|
|
639
|
+
return {
|
|
640
|
+
status: tree.status,
|
|
641
|
+
fileCount: tree.fileCount,
|
|
642
|
+
hash: tree.hash,
|
|
643
|
+
categories: tree.categories,
|
|
644
|
+
reasons: tree.reasons,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isKernelSidecar(relativePath) {
|
|
649
|
+
return /^\.forge\/kernel\.sqlite(?:-(?:wal|shm))?$/.test(relativePath);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function validateContainedDirectory(rootIdentity, directoryPath, reason) {
|
|
653
|
+
const stat = fs.lstatSync(directoryPath);
|
|
654
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new SecurityEvidenceError(reason);
|
|
655
|
+
const identity = fs.realpathSync.native(directoryPath);
|
|
656
|
+
if (!isContained(rootIdentity, identity)) throw new SecurityEvidenceError(reason);
|
|
657
|
+
return identity;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function validateBoundDirectory(directoryPath, expectedIdentity, reason) {
|
|
661
|
+
const identity = validateContainedDirectory(expectedIdentity, directoryPath, reason);
|
|
662
|
+
if (identity !== expectedIdentity) throw new SecurityEvidenceError(reason);
|
|
663
|
+
return identity;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function ensureSecureParent(rootPath, rootIdentity, relativePath) {
|
|
667
|
+
validateBoundDirectory(rootPath, rootIdentity, 'destination_root_changed');
|
|
668
|
+
let current = rootPath;
|
|
669
|
+
for (const part of path.dirname(relativePath).split(/[\\/]/).filter(part => part && part !== '.')) {
|
|
670
|
+
current = path.join(current, part);
|
|
671
|
+
try {
|
|
672
|
+
fs.mkdirSync(current);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error.code !== 'EEXIST') throw error;
|
|
675
|
+
}
|
|
676
|
+
validateContainedDirectory(rootIdentity, current, 'destination_link_refused');
|
|
677
|
+
}
|
|
678
|
+
return rootIdentity;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function assertDestinationContext(context) {
|
|
682
|
+
validateBoundDirectory(context.proofRoot, context.proofIdentity, 'proof_root_changed');
|
|
683
|
+
validateBoundDirectory(context.destinationRoot, context.destinationIdentity, 'destination_root_changed');
|
|
684
|
+
if (!isContained(context.proofIdentity, context.destinationIdentity)) {
|
|
685
|
+
throw new SecurityEvidenceError('destination_root_changed');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function copyOpenDescriptors(sourceDescriptor, targetDescriptor, expectedSize, budget) {
|
|
690
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
691
|
+
let total = 0;
|
|
692
|
+
for (;;) {
|
|
693
|
+
budget.check();
|
|
694
|
+
const read = fs.readSync(sourceDescriptor, buffer, 0, buffer.length, null);
|
|
695
|
+
if (read === 0) break;
|
|
696
|
+
total += read;
|
|
697
|
+
if (total > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('state_tree_file_size_limit');
|
|
698
|
+
let written = 0;
|
|
699
|
+
while (written < read) written += fs.writeSync(targetDescriptor, buffer, written, read - written);
|
|
700
|
+
}
|
|
701
|
+
if (total !== expectedSize) throw new SecurityEvidenceError('source_changed_during_copy');
|
|
702
|
+
fs.fsyncSync(targetDescriptor);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function sameFileIdentity(before, after) {
|
|
706
|
+
return before.size === after.size && before.dev === after.dev && before.ino === after.ino;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function copyFileSecure(sourceRoot, destinationContext, file, budget, testHooks = {}) {
|
|
710
|
+
budget.check();
|
|
711
|
+
if (!isSafeRelativePath(file.path)) throw new SecurityEvidenceError('source_path_refused');
|
|
712
|
+
const sourceIdentity = validateContainedDirectory(
|
|
713
|
+
fs.realpathSync.native(sourceRoot),
|
|
714
|
+
sourceRoot,
|
|
715
|
+
'source_root_changed',
|
|
716
|
+
);
|
|
717
|
+
const sourcePath = path.join(sourceRoot, file.path);
|
|
718
|
+
testHooks.beforeSourceOpen?.({ sourcePath });
|
|
719
|
+
const sourceStat = fs.lstatSync(sourcePath);
|
|
720
|
+
if (sourceStat.isSymbolicLink()) throw new SecurityEvidenceError('source_link_refused');
|
|
721
|
+
const sourceRealPath = fs.realpathSync.native(sourcePath);
|
|
722
|
+
if (!isContained(sourceIdentity, sourceRealPath)) throw new SecurityEvidenceError('source_link_refused');
|
|
723
|
+
if (!sourceStat.isFile()) throw new SecurityEvidenceError('source_not_regular');
|
|
724
|
+
if (sourceStat.size > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('state_tree_file_size_limit');
|
|
725
|
+
|
|
726
|
+
testHooks.beforeDestinationOpen?.();
|
|
727
|
+
assertDestinationContext(destinationContext);
|
|
728
|
+
const destinationIdentity = ensureSecureParent(
|
|
729
|
+
destinationContext.destinationRoot,
|
|
730
|
+
destinationContext.destinationIdentity,
|
|
731
|
+
file.path,
|
|
732
|
+
);
|
|
733
|
+
const target = path.join(destinationContext.destinationRoot, file.path);
|
|
734
|
+
let sourceDescriptor;
|
|
735
|
+
let targetDescriptor;
|
|
736
|
+
try {
|
|
737
|
+
sourceDescriptor = fs.openSync(sourcePath, readOnlyOpenFlags());
|
|
738
|
+
const openedSource = fs.fstatSync(sourceDescriptor);
|
|
739
|
+
const currentSourceStat = fs.lstatSync(sourcePath);
|
|
740
|
+
const currentSourceIdentity = currentSourceStat.isSymbolicLink() ? null : fs.realpathSync.native(sourcePath);
|
|
741
|
+
if (!openedSource.isFile() || !sameFileIdentity(sourceStat, openedSource)
|
|
742
|
+
|| !sameFileIdentity(sourceStat, currentSourceStat) || currentSourceIdentity !== sourceRealPath) {
|
|
743
|
+
throw new SecurityEvidenceError('source_changed_during_copy');
|
|
744
|
+
}
|
|
745
|
+
assertDestinationContext(destinationContext);
|
|
746
|
+
validateContainedDirectory(destinationIdentity, path.dirname(target), 'destination_link_refused');
|
|
747
|
+
targetDescriptor = fs.openSync(target, exclusiveWriteOpenFlags(), 0o600);
|
|
748
|
+
copyOpenDescriptors(sourceDescriptor, targetDescriptor, openedSource.size, budget);
|
|
749
|
+
} finally {
|
|
750
|
+
if (targetDescriptor !== undefined) fs.closeSync(targetDescriptor);
|
|
751
|
+
if (sourceDescriptor !== undefined) fs.closeSync(sourceDescriptor);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function copyManifestFiles(sourceRoot, destinationContext, files, budget, testHooks) {
|
|
756
|
+
for (const file of files) {
|
|
757
|
+
copyFileSecure(sourceRoot, destinationContext, file, budget, testHooks);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function normalizeSqlValue(value) {
|
|
762
|
+
if (typeof value === 'bigint') return value.toString();
|
|
763
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value).toString('hex');
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function normalizeSqlRow(row) {
|
|
768
|
+
const keys = Object.keys(row);
|
|
769
|
+
keys.sort(compareCanonicalText);
|
|
770
|
+
return Object.fromEntries(keys.map(key => [key, normalizeSqlValue(row[key])]));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function quoteIdentifier(identifier) {
|
|
774
|
+
return `"${String(identifier).replaceAll('"', '""')}"`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function boundedTableRows(runtime, database, table, count, budget) {
|
|
778
|
+
const columns = queryAll(runtime, database, `PRAGMA table_info(${quoteIdentifier(table)});`)
|
|
779
|
+
.slice(0, budget.limits.maxSqliteTables + 1)
|
|
780
|
+
.map(row => String(row.name));
|
|
781
|
+
if (columns.length > budget.limits.maxSqliteTables) throw new IncompleteEvidenceError('kernel_column_limit');
|
|
782
|
+
const selection = columns.map(quoteIdentifier).join(', ');
|
|
783
|
+
const rowHashes = [];
|
|
784
|
+
let contentBytes = 0;
|
|
785
|
+
for (let offset = 0; offset < count; offset += 256) {
|
|
786
|
+
budget.check();
|
|
787
|
+
const limit = Math.min(256, count - offset);
|
|
788
|
+
const page = queryAll(
|
|
789
|
+
runtime,
|
|
790
|
+
database,
|
|
791
|
+
`SELECT ${selection} FROM ${quoteIdentifier(table)} ORDER BY rowid LIMIT ${limit} OFFSET ${offset};`,
|
|
792
|
+
);
|
|
793
|
+
for (const row of page) {
|
|
794
|
+
const encoded = JSON.stringify(normalizeSqlRow(row));
|
|
795
|
+
contentBytes += Buffer.byteLength(encoded);
|
|
796
|
+
if (contentBytes > budget.limits.maxSqliteContentBytes) {
|
|
797
|
+
throw new IncompleteEvidenceError('kernel_content_byte_limit');
|
|
798
|
+
}
|
|
799
|
+
rowHashes.push(sha256(encoded));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
rowHashes.sort(compareCanonicalText);
|
|
803
|
+
return { contentBytes, rowHashes };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function appendTableSignature(runtime, database, table, budget, contentHash, counts, totals) {
|
|
807
|
+
const count = Number(queryOne(
|
|
808
|
+
runtime,
|
|
809
|
+
database,
|
|
810
|
+
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table)};`,
|
|
811
|
+
).count || 0);
|
|
812
|
+
if (count > budget.limits.maxSqliteRows) throw new IncompleteEvidenceError('kernel_row_limit');
|
|
813
|
+
counts[table] = count;
|
|
814
|
+
const bounded = boundedTableRows(runtime, database, table, count, budget);
|
|
815
|
+
totals.contentBytes += bounded.contentBytes;
|
|
816
|
+
if (totals.contentBytes > budget.limits.maxSqliteContentBytes) {
|
|
817
|
+
throw new IncompleteEvidenceError('kernel_content_byte_limit');
|
|
818
|
+
}
|
|
819
|
+
contentHash.update(`${table}\0${bounded.rowHashes.join('\n')}\n`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function kernelSemanticSignature(databasePath, options = {}) {
|
|
823
|
+
const budget = options.budget || createBudget(options);
|
|
824
|
+
checkRegularFile(databasePath, budget, 'kernel_file_size_limit');
|
|
825
|
+
const runtime = selectBuiltinSQLiteRuntime();
|
|
826
|
+
const database = openReadOnlyDatabase(runtime, databasePath);
|
|
827
|
+
try {
|
|
828
|
+
budget.check();
|
|
829
|
+
const integrity = String(queryOne(runtime, database, 'PRAGMA quick_check;').quick_check || 'unknown');
|
|
830
|
+
const schemaRows = queryAll(
|
|
831
|
+
runtime,
|
|
832
|
+
database,
|
|
833
|
+
`SELECT name, type, sql FROM sqlite_master WHERE name LIKE 'kernel_%' `
|
|
834
|
+
+ `ORDER BY type, name LIMIT ${budget.limits.maxSqliteTables + 1};`,
|
|
835
|
+
).map(normalizeSqlRow);
|
|
836
|
+
if (schemaRows.length > budget.limits.maxSqliteTables) {
|
|
837
|
+
throw new IncompleteEvidenceError('kernel_table_limit');
|
|
838
|
+
}
|
|
839
|
+
const presentTables = new Set(schemaRows.filter(row => row.type === 'table').map(row => row.name));
|
|
840
|
+
const counts = {};
|
|
841
|
+
const contentHash = crypto.createHash('sha256');
|
|
842
|
+
const totals = { contentBytes: 0 };
|
|
843
|
+
for (const table of REQUIRED_KERNEL_TABLES) {
|
|
844
|
+
budget.check();
|
|
845
|
+
if (!presentTables.has(table)) continue;
|
|
846
|
+
appendTableSignature(runtime, database, table, budget, contentHash, counts, totals);
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
integrity,
|
|
850
|
+
schemaHash: sha256(JSON.stringify(schemaRows)),
|
|
851
|
+
contentHash: contentHash.digest('hex'),
|
|
852
|
+
counts,
|
|
853
|
+
};
|
|
854
|
+
} finally {
|
|
855
|
+
closeDatabase(database);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function writeBufferSecure(destinationContext, relativePath, bytes, budget, testHooks) {
|
|
860
|
+
if (bytes.length > budget.limits.maxFileBytes) throw new IncompleteEvidenceError('kernel_file_size_limit');
|
|
861
|
+
testHooks.beforeKernelBackupWrite?.();
|
|
862
|
+
assertDestinationContext(destinationContext);
|
|
863
|
+
const destinationIdentity = ensureSecureParent(
|
|
864
|
+
destinationContext.destinationRoot,
|
|
865
|
+
destinationContext.destinationIdentity,
|
|
866
|
+
relativePath,
|
|
867
|
+
);
|
|
868
|
+
const target = path.join(destinationContext.destinationRoot, relativePath);
|
|
869
|
+
assertDestinationContext(destinationContext);
|
|
870
|
+
validateContainedDirectory(destinationIdentity, path.dirname(target), 'destination_link_refused');
|
|
871
|
+
const descriptor = fs.openSync(target, exclusiveWriteOpenFlags(), 0o600);
|
|
872
|
+
try {
|
|
873
|
+
let written = 0;
|
|
874
|
+
while (written < bytes.length) {
|
|
875
|
+
budget.check();
|
|
876
|
+
written += fs.writeSync(descriptor, bytes, written, bytes.length - written);
|
|
877
|
+
}
|
|
878
|
+
fs.fsyncSync(descriptor);
|
|
879
|
+
} finally {
|
|
880
|
+
fs.closeSync(descriptor);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function createKernelBackup(sourcePath, destinationContext, relativePath, budget, options = {}) {
|
|
885
|
+
checkRegularFile(sourcePath, budget, 'kernel_file_size_limit');
|
|
886
|
+
const runtime = options.backupRuntime || selectBuiltinSQLiteRuntime();
|
|
887
|
+
const testHooks = options.testHooks || {};
|
|
888
|
+
const database = openReadOnlyDatabase(runtime, sourcePath);
|
|
889
|
+
try {
|
|
890
|
+
if (runtime.id === 'bun:sqlite') {
|
|
891
|
+
if (typeof database.serialize !== 'function') throw new Error('bun sqlite backup unavailable');
|
|
892
|
+
writeBufferSecure(destinationContext, relativePath, Buffer.from(database.serialize()), budget, testHooks);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
testHooks.beforeKernelBackupWrite?.();
|
|
896
|
+
assertDestinationContext(destinationContext);
|
|
897
|
+
const backupPath = path.join(destinationContext.destinationRoot, relativePath);
|
|
898
|
+
ensureSecureParent(destinationContext.destinationRoot, destinationContext.destinationIdentity, relativePath);
|
|
899
|
+
assertDestinationContext(destinationContext);
|
|
900
|
+
testHooks.beforeNodeBackupTargetCheck?.({ backupPath });
|
|
901
|
+
if (fs.lstatSync(backupPath, { throwIfNoEntry: false })) {
|
|
902
|
+
throw new SecurityEvidenceError('destination_link_refused');
|
|
903
|
+
}
|
|
904
|
+
if (typeof runtime.module.backup === 'function') {
|
|
905
|
+
await runtime.module.backup(database, backupPath);
|
|
906
|
+
} else if (typeof database.backup === 'function') {
|
|
907
|
+
await database.backup(backupPath);
|
|
908
|
+
} else {
|
|
909
|
+
throw new TypeError('node sqlite backup unavailable');
|
|
910
|
+
}
|
|
911
|
+
const targetStat = fs.lstatSync(backupPath);
|
|
912
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) throw new SecurityEvidenceError('destination_link_refused');
|
|
913
|
+
assertDestinationContext(destinationContext);
|
|
914
|
+
validateContainedDirectory(destinationContext.destinationIdentity, path.dirname(backupPath), 'destination_link_refused');
|
|
915
|
+
} finally {
|
|
916
|
+
closeDatabase(database);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function validateProofRoots(sourceRoot, proofRoot) {
|
|
921
|
+
try {
|
|
922
|
+
const sourceStat = fs.lstatSync(sourceRoot);
|
|
923
|
+
const proofStat = fs.lstatSync(proofRoot);
|
|
924
|
+
const tempStat = fs.lstatSync(os.tmpdir());
|
|
925
|
+
if (sourceStat.isSymbolicLink() || proofStat.isSymbolicLink() || tempStat.isSymbolicLink()) {
|
|
926
|
+
return { error: 'proof_root_link_refused' };
|
|
927
|
+
}
|
|
928
|
+
if (!sourceStat.isDirectory() || !proofStat.isDirectory() || !tempStat.isDirectory()) {
|
|
929
|
+
return { error: 'proof_root_unavailable' };
|
|
930
|
+
}
|
|
931
|
+
const source = fs.realpathSync.native(sourceRoot);
|
|
932
|
+
const proof = fs.realpathSync.native(proofRoot);
|
|
933
|
+
const temporary = fs.realpathSync.native(os.tmpdir());
|
|
934
|
+
if (!isContained(temporary, proof) || proof === temporary) return { error: 'proof_root_not_temporary' };
|
|
935
|
+
if (isContained(source, proof) || isContained(proof, source)) return { error: 'proof_root_overlaps_source' };
|
|
936
|
+
if (fs.readdirSync(proofRoot).length > 0) return { error: 'proof_destination_not_empty' };
|
|
937
|
+
return { proofIdentity: proof };
|
|
938
|
+
} catch (_error) { // NOSONAR S2486 -- invalid proof roots fail closed to a stable reason.
|
|
939
|
+
return { error: 'proof_root_unavailable' };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function compareKernelSignatures(source, restored) {
|
|
944
|
+
return {
|
|
945
|
+
integrity: source.integrity === 'ok' && restored.integrity === 'ok' ? 'ok' : 'failed',
|
|
946
|
+
schemaHashMatched: source.schemaHash === restored.schemaHash,
|
|
947
|
+
contentHashMatched: source.contentHash === restored.contentHash,
|
|
948
|
+
countsMatched: JSON.stringify(source.counts) === JSON.stringify(restored.counts),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function createSecureProofChild(proofRoot, proofIdentity, name) {
|
|
953
|
+
validateBoundDirectory(proofRoot, proofIdentity, 'proof_root_changed');
|
|
954
|
+
const child = path.join(proofRoot, name);
|
|
955
|
+
fs.mkdirSync(child);
|
|
956
|
+
const destinationIdentity = validateContainedDirectory(proofIdentity, child, 'proof_root_changed');
|
|
957
|
+
validateBoundDirectory(proofRoot, proofIdentity, 'proof_root_changed');
|
|
958
|
+
return { proofRoot, proofIdentity, destinationRoot: child, destinationIdentity };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function privateEvidenceSummary(files) {
|
|
962
|
+
return {
|
|
963
|
+
fileCount: files.length,
|
|
964
|
+
categories: summarizeCategories(files),
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Prove isolated byte and semantic restoration while leaving the source untouched. */
|
|
969
|
+
async function proveBackupRestore(sourceRoot, proofRoot, options = {}) {
|
|
970
|
+
const proofValidation = validateProofRoots(sourceRoot, proofRoot);
|
|
971
|
+
if (proofValidation.error) {
|
|
972
|
+
return { status: 'FAIL', reason: proofValidation.error, fileCount: 0, categories: {} };
|
|
973
|
+
}
|
|
974
|
+
const budget = createBudget(options);
|
|
975
|
+
const sourceManifest = scanStateTree(sourceRoot, { budget });
|
|
976
|
+
const summary = privateEvidenceSummary(sourceManifest.files);
|
|
977
|
+
if (sourceManifest.status !== 'PASS') {
|
|
978
|
+
return { status: 'INCOMPLETE', reason: sourceManifest.reasons[0] || 'source_tree_incomplete', ...summary };
|
|
979
|
+
}
|
|
980
|
+
if (sourceManifest.fileCount === 0) return { status: 'INCOMPLETE', reason: 'no_known_state_files', ...summary };
|
|
981
|
+
const ordinaryFiles = sourceManifest.files.filter(file => !isKernelSidecar(file.path));
|
|
982
|
+
const kernelRelativePath = '.forge/kernel.sqlite';
|
|
983
|
+
const hasKernel = sourceManifest.files.some(file => file.path === kernelRelativePath);
|
|
984
|
+
try {
|
|
985
|
+
const backup = createSecureProofChild(proofRoot, proofValidation.proofIdentity, 'backup');
|
|
986
|
+
const restore = createSecureProofChild(proofRoot, proofValidation.proofIdentity, 'restore');
|
|
987
|
+
copyManifestFiles(sourceRoot, backup, ordinaryFiles, budget, options.testHooks);
|
|
988
|
+
let sourceKernelSignature = null;
|
|
989
|
+
if (hasKernel) {
|
|
990
|
+
sourceKernelSignature = kernelSemanticSignature(path.join(sourceRoot, kernelRelativePath), { budget });
|
|
991
|
+
await createKernelBackup(
|
|
992
|
+
path.join(sourceRoot, kernelRelativePath),
|
|
993
|
+
backup,
|
|
994
|
+
kernelRelativePath,
|
|
995
|
+
budget,
|
|
996
|
+
options,
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
const backupFiles = [...ordinaryFiles];
|
|
1000
|
+
if (hasKernel) backupFiles.push({ path: kernelRelativePath, category: 'kernel' });
|
|
1001
|
+
copyManifestFiles(backup.destinationRoot, restore, backupFiles, budget);
|
|
1002
|
+
const sourceHash = aggregateHash(ordinaryFiles);
|
|
1003
|
+
const backupHash = aggregateHash(ordinaryFiles.map(file => ({
|
|
1004
|
+
path: file.path,
|
|
1005
|
+
sha256: hashFileBounded(path.join(backup.destinationRoot, file.path), budget),
|
|
1006
|
+
})));
|
|
1007
|
+
const restoreHash = aggregateHash(ordinaryFiles.map(file => ({
|
|
1008
|
+
path: file.path,
|
|
1009
|
+
sha256: hashFileBounded(path.join(restore.destinationRoot, file.path), budget),
|
|
1010
|
+
})));
|
|
1011
|
+
const kernel = hasKernel
|
|
1012
|
+
? compareKernelSignatures(
|
|
1013
|
+
sourceKernelSignature,
|
|
1014
|
+
kernelSemanticSignature(path.join(restore.destinationRoot, kernelRelativePath), { budget }),
|
|
1015
|
+
)
|
|
1016
|
+
: null;
|
|
1017
|
+
const kernelMatches = !kernel || (kernel.integrity === 'ok' && kernel.schemaHashMatched
|
|
1018
|
+
&& kernel.contentHashMatched && kernel.countsMatched);
|
|
1019
|
+
const fileBytesMatched = sourceHash === backupHash && backupHash === restoreHash;
|
|
1020
|
+
return {
|
|
1021
|
+
status: fileBytesMatched && kernelMatches ? 'PASS' : 'FAIL',
|
|
1022
|
+
reason: fileBytesMatched && kernelMatches ? null : 'backup_restore_mismatch',
|
|
1023
|
+
...summary,
|
|
1024
|
+
fileBytesMatched,
|
|
1025
|
+
backupHash,
|
|
1026
|
+
restoreHash,
|
|
1027
|
+
kernel,
|
|
1028
|
+
};
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
return {
|
|
1031
|
+
status: error instanceof IncompleteEvidenceError ? 'INCOMPLETE' : 'FAIL',
|
|
1032
|
+
reason: error.reason || 'backup_restore_io_failure',
|
|
1033
|
+
...summary,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function summarizeTreeManifest(manifest) {
|
|
1039
|
+
return {
|
|
1040
|
+
status: manifest.status,
|
|
1041
|
+
fileCount: manifest.fileCount,
|
|
1042
|
+
hash: manifest.hash,
|
|
1043
|
+
categories: manifest.categories,
|
|
1044
|
+
reasons: manifest.reasons,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Produce a deterministic compatibility verdict from inventory and restore evidence. */
|
|
1049
|
+
async function runBeta5MigrationDryRun(projectRoot, options = {}) {
|
|
1050
|
+
const before = buildStateTreeManifest(projectRoot, options);
|
|
1051
|
+
const corpus = verifyBeta5Corpus(options.corpusRoot || BETA5_CORPUS_ROOT, options);
|
|
1052
|
+
const inventory = buildPrivacySafeInventory(projectRoot, options);
|
|
1053
|
+
const backup = options.proofRoot
|
|
1054
|
+
? await proveBackupRestore(projectRoot, options.proofRoot, options)
|
|
1055
|
+
: { status: 'INCOMPLETE', reason: 'proof_root_not_provided', fileCount: 0, categories: {} };
|
|
1056
|
+
const after = buildStateTreeManifest(projectRoot, options);
|
|
1057
|
+
const sourceMutated = before.hash !== after.hash || before.fileCount !== after.fileCount;
|
|
1058
|
+
const reasons = [];
|
|
1059
|
+
if (corpus.status === 'FAIL') reasons.push('corpus_integrity_failed');
|
|
1060
|
+
if (corpus.status === 'INCOMPLETE') reasons.push('corpus_verification_incomplete');
|
|
1061
|
+
if (inventory.status === 'INCOMPLETE') reasons.push('inventory_incomplete');
|
|
1062
|
+
if (before.status === 'INCOMPLETE' || after.status === 'INCOMPLETE') reasons.push('source_tree_incomplete');
|
|
1063
|
+
if (backup.status === 'FAIL') reasons.push('backup_restore_failed');
|
|
1064
|
+
if (backup.status === 'INCOMPLETE') reasons.push('backup_restore_incomplete');
|
|
1065
|
+
if (sourceMutated) reasons.push('source_mutated');
|
|
1066
|
+
const failed = corpus.status === 'FAIL' || backup.status === 'FAIL' || sourceMutated;
|
|
1067
|
+
const incomplete = corpus.status === 'INCOMPLETE' || inventory.status === 'INCOMPLETE' || backup.status === 'INCOMPLETE'
|
|
1068
|
+
|| before.status === 'INCOMPLETE' || after.status === 'INCOMPLETE';
|
|
1069
|
+
let status = 'PASS';
|
|
1070
|
+
if (failed) status = 'FAIL';
|
|
1071
|
+
else if (incomplete) status = 'INCOMPLETE';
|
|
1072
|
+
return {
|
|
1073
|
+
schemaVersion: 'forge.beta5-migration-dry-run.v1',
|
|
1074
|
+
status,
|
|
1075
|
+
corpus,
|
|
1076
|
+
inventory,
|
|
1077
|
+
backup,
|
|
1078
|
+
sourceTree: { before: summarizeTreeManifest(before), after: summarizeTreeManifest(after) },
|
|
1079
|
+
sourceMutated,
|
|
1080
|
+
reasons,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
module.exports = {
|
|
1085
|
+
BETA5_CORPUS_ROOT,
|
|
1086
|
+
BETA5_RELEASE_COMMIT,
|
|
1087
|
+
BETA5_VERSION,
|
|
1088
|
+
buildPrivacySafeInventory,
|
|
1089
|
+
buildStateTreeManifest,
|
|
1090
|
+
proveBackupRestore,
|
|
1091
|
+
runBeta5MigrationDryRun,
|
|
1092
|
+
verifyBeta5Corpus,
|
|
1093
|
+
};
|