@1aboveio/skills 1.0.18 → 1.0.20
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +151 -145
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +5 -4
- package/skills/engineering/better-review/references/passes.md +15 -0
- package/skills/engineering/better-review/references/result.md +14 -0
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/SKILL.md +14 -9
- package/skills/engineering/resolve-issues/references/ci.md +3 -1
- package/skills/engineering/resolve-issues/references/delivery-progress.md +16 -0
- package/skills/engineering/resolve-issues/references/done.md +7 -0
- package/skills/engineering/resolve-issues/references/dropbox.md +31 -6
- package/skills/engineering/resolve-issues/references/expire.md +6 -3
- package/skills/engineering/resolve-issues/references/implementation.md +67 -6
- package/skills/engineering/resolve-issues/references/preflight.md +17 -3
- package/skills/engineering/resolve-issues/references/retention.md +162 -0
- package/skills/engineering/resolve-issues/references/review.md +42 -9
- package/skills/engineering/resolve-issues/references/task-plan.md +17 -2
- package/skills/engineering/resolve-issues/references/workspaces.md +40 -10
- package/skills/engineering/resolve-issues/scripts/daily-cleanup.mjs +169 -0
- package/skills/engineering/resolve-issues/scripts/dropbox.mjs +2 -1
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +79 -4
- package/skills/engineering/resolve-issues/scripts/resource-formats.mjs +625 -0
- package/skills/engineering/resolve-issues/scripts/retention-lock.mjs +548 -0
- package/skills/engineering/resolve-issues/scripts/retention-plans.mjs +57 -0
- package/skills/engineering/resolve-issues/scripts/retention.mjs +2314 -0
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +63 -10
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// Typed proof for generated resource formats and the source-owned inputs that rebuild them.
|
|
2
|
+
//
|
|
3
|
+
// retention.mjs is deliberately strict in two places: a run-metadata name such as
|
|
4
|
+
// `source`, `sources`, `results` or `raw` is never disposable, and a symbolic link that
|
|
5
|
+
// points outside the target blocks removal. Both rules are right for a generic resource
|
|
6
|
+
// and wrong for a proven package-manager or build artifact: real installed dependencies
|
|
7
|
+
// ship a `source/` directory, and a virtual environment's `bin/python` is a reference to
|
|
8
|
+
// the base interpreter by design. A blanket name guard therefore rejects genuine vendor
|
|
9
|
+
// content, which is the reviewed defect this module narrows.
|
|
10
|
+
//
|
|
11
|
+
// This module is the narrow, read-only exception. It proves one typed format plus the
|
|
12
|
+
// canonical, source-owned rebuild inputs that reproduce it, and reports which two
|
|
13
|
+
// generic guards the caller may relax for that resource. It never deletes, registers,
|
|
14
|
+
// quarantines, or prints a rebuild command, and it never executes anything: proof is
|
|
15
|
+
// bounded lstat/readdir/hash work plus, for two manifests, JSON.parse. External symbolic
|
|
16
|
+
// links inside a proven format are REFERENCES, so cleanup unlinks the link without
|
|
17
|
+
// following it; no code here traverses one. Credential policy is not this module's: the
|
|
18
|
+
// engine's credential guard stays in force for every resource, typed or not.
|
|
19
|
+
//
|
|
20
|
+
// API
|
|
21
|
+
// inspectResourceFormat({ format, path, inputs, inspection? })
|
|
22
|
+
// -> { ok, state, reason, format, root, markers, inputProofs,
|
|
23
|
+
// allowEmbeddedSourceNames, allowReferenceSymlinks,
|
|
24
|
+
// embeddedSourceNames, blockingMetadata, referenceSymlinks, retainInputs }
|
|
25
|
+
// Re-proves the markers and proves the rebuild inputs from disk. `inspection` is
|
|
26
|
+
// optional: pass the caller's own tree inspection ({ ok, reason, metadataNames,
|
|
27
|
+
// externalSymlinks }) so the name verdict and the external-link verdict reuse one
|
|
28
|
+
// walk. With no inspection, no name or link scan happens here at all.
|
|
29
|
+
// validateRebuildInputs(inputProofs)
|
|
30
|
+
// -> { ok, reason, checked, changed: [{ name, path, kind, detail }] }
|
|
31
|
+
// Re-reads each recorded input and reports missing / path / identity / hardlink /
|
|
32
|
+
// content changes against the recorded proof. Read-only.
|
|
33
|
+
// classifyRebuildState({ format, path, inputProofs, inspection? })
|
|
34
|
+
// -> { ok, state: 'ready'|'missing'|'partial'|'inputs-changed'|'unproven', ... }
|
|
35
|
+
// Re-proves the markers (cheap, targeted) and validates the recorded inputs, without
|
|
36
|
+
// writing. A root that looks present but is not usable after a manual cleanup stays
|
|
37
|
+
// 'partial', never 'ready'. Pass `inspection` too and run metadata inside the tree
|
|
38
|
+
// keeps the same 'partial' verdict inspectResourceFormat reports.
|
|
39
|
+
// classifyEmbeddedNames({ format, names })
|
|
40
|
+
// -> { format, allowEmbeddedSourceNames, allowed, blocking }
|
|
41
|
+
// Pure split of the caller's metadata names into the vendored names a typed format
|
|
42
|
+
// may embed and the run metadata that still blocks removal.
|
|
43
|
+
//
|
|
44
|
+
// Formats and the canonical rebuild inputs each one needs (all inputs must be canonical
|
|
45
|
+
// regular files outside the target, at most 4 inputs, at most 16 MiB each, one hard
|
|
46
|
+
// link, no symbolic link; a JSON manifest must parse to an object):
|
|
47
|
+
// pnpm-install <project>/node_modules .modules.yaml + .pnpm/<package@version>/
|
|
48
|
+
// package.json + pnpm-lock.yaml
|
|
49
|
+
// uv-venv <project>/.venv pyvenv.cfg + bin/python reference +
|
|
50
|
+
// lib/python*/site-packages
|
|
51
|
+
// pyproject.toml + uv.lock
|
|
52
|
+
// next-build <project>/.next BUILD_ID + build-manifest.json + server/
|
|
53
|
+
// package.json + pnpm-lock.yaml
|
|
54
|
+
// pnpm-store <project>/.pnpm-store v<N>/files/<bucket>/<content-addressed>
|
|
55
|
+
// package.json + pnpm-lock.yaml
|
|
56
|
+
// Anything else, including no format at all, is `generic`: no typed proof exists, `ok`
|
|
57
|
+
// is false, and both allowances are false, so the caller keeps its existing strict
|
|
58
|
+
// checks. A folder name never creates authority.
|
|
59
|
+
//
|
|
60
|
+
// The caller names the rebuild inputs. This module proves that the named files are the
|
|
61
|
+
// canonical ones for the format, sit outside the target, and are unchanged since the
|
|
62
|
+
// proof; it records their content hash and identity. It cannot decide that a particular
|
|
63
|
+
// lockfile is the one that produced a particular artifact, so choosing an owner's own
|
|
64
|
+
// inputs stays with the caller (retention.mjs, from the unit's project).
|
|
65
|
+
//
|
|
66
|
+
// Integration sketch (retention.mjs owns the real wiring):
|
|
67
|
+
// const proof = inspectResourceFormat({
|
|
68
|
+
// format: entry.resourceFormat, path: entry.path, inputs: rebuildInputsFor(entry),
|
|
69
|
+
// inspection,
|
|
70
|
+
// })
|
|
71
|
+
// if (!proof.ok) retain(entry, proof.reason) // generic rules, unchanged
|
|
72
|
+
// else remove(entry.path, { unlinkLinks: proof.referenceSymlinks }) // unlink, never follow
|
|
73
|
+
|
|
74
|
+
import { createHash } from 'node:crypto'
|
|
75
|
+
import {
|
|
76
|
+
closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readSync, readdirSync,
|
|
77
|
+
readlinkSync, realpathSync,
|
|
78
|
+
} from 'node:fs'
|
|
79
|
+
import { basename, isAbsolute, join, resolve } from 'node:path'
|
|
80
|
+
import { containsPath, normPath } from '../../harness-runtime/workspace-lifecycle.mjs'
|
|
81
|
+
|
|
82
|
+
// Finite bounds. The input budget is a ceiling for one proof, not a size policy: two
|
|
83
|
+
// source-owned configuration files comfortably fit, and a bound is still enforced.
|
|
84
|
+
export const MAX_REBUILD_INPUTS = 4
|
|
85
|
+
export const MAX_REBUILD_INPUT_BYTES = 16 * 1024 * 1024
|
|
86
|
+
const HASH_CHUNK_BYTES = 1024 * 1024
|
|
87
|
+
const MAX_MARKER_ENTRIES = 4096
|
|
88
|
+
const MAX_BUCKET_ENTRIES = 512
|
|
89
|
+
const MAX_BUCKETS_SCANNED = 64
|
|
90
|
+
const MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
// The four run-metadata names that real installed dependencies and build outputs
|
|
93
|
+
// legitimately contain. Only a proven typed format may embed these.
|
|
94
|
+
const VENDORED_SOURCE_NAMES = new Set(['source', 'sources', 'results', 'raw'])
|
|
95
|
+
// Run metadata that stays blocking even inside a proven typed format. A generated tree
|
|
96
|
+
// is never the owner of a plan, a finding record, a checkpoint, or a Git repository.
|
|
97
|
+
const RUN_METADATA_NAMES = new Set([
|
|
98
|
+
'task-plan.json', 'issue-tree.json', 'taskplan.json', 'dropbox.json', 'findings.json',
|
|
99
|
+
'next-actions-seen.json', 'decisions', 'findings', 'reviewverdicts', 'review-verdicts',
|
|
100
|
+
'briefs', 'profiling', 'checkpoints', '.git',
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const FORMATS = Object.freeze({
|
|
104
|
+
'pnpm-install': Object.freeze({
|
|
105
|
+
inputs: Object.freeze(['package.json', 'pnpm-lock.yaml']),
|
|
106
|
+
jsonInputs: Object.freeze(['package.json']),
|
|
107
|
+
}),
|
|
108
|
+
'uv-venv': Object.freeze({
|
|
109
|
+
inputs: Object.freeze(['pyproject.toml', 'uv.lock']),
|
|
110
|
+
jsonInputs: Object.freeze([]),
|
|
111
|
+
}),
|
|
112
|
+
'next-build': Object.freeze({
|
|
113
|
+
inputs: Object.freeze(['package.json', 'pnpm-lock.yaml']),
|
|
114
|
+
jsonInputs: Object.freeze(['package.json']),
|
|
115
|
+
}),
|
|
116
|
+
'pnpm-store': Object.freeze({
|
|
117
|
+
inputs: Object.freeze(['package.json', 'pnpm-lock.yaml']),
|
|
118
|
+
jsonInputs: Object.freeze(['package.json']),
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
121
|
+
export const TYPED_FORMATS = Object.freeze(Object.keys(FORMATS))
|
|
122
|
+
|
|
123
|
+
const CA_FILE_NAME = /^[0-9a-f]{32,}(?:[-._][A-Za-z0-9._-]+)?$/i
|
|
124
|
+
const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
|
|
125
|
+
|
|
126
|
+
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
|
|
127
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0
|
|
128
|
+
const real = (p) => (realpathSync.native ? realpathSync.native(p) : realpathSync(p))
|
|
129
|
+
|
|
130
|
+
function formatId(value) {
|
|
131
|
+
const id = typeof value === 'string' ? value.trim() : ''
|
|
132
|
+
return Object.hasOwn(FORMATS, id) ? id : 'generic'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function genericReason(requested) {
|
|
136
|
+
const named = typeof requested === 'string' && requested.trim() ? `'${requested.trim()}' is not a typed format` : 'no typed format was requested'
|
|
137
|
+
return `${named}; generic resources keep the existing strict metadata-name and external-link checks`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function verdict(fields) {
|
|
141
|
+
return {
|
|
142
|
+
ok: fields.ok === true,
|
|
143
|
+
state: fields.state,
|
|
144
|
+
reason: fields.reason ?? null,
|
|
145
|
+
format: fields.format ?? 'generic',
|
|
146
|
+
root: fields.root ?? null,
|
|
147
|
+
markers: fields.markers ?? [],
|
|
148
|
+
inputProofs: fields.inputProofs ?? null,
|
|
149
|
+
allowEmbeddedSourceNames: fields.allowEmbeddedSourceNames === true,
|
|
150
|
+
allowReferenceSymlinks: fields.allowReferenceSymlinks === true,
|
|
151
|
+
embeddedSourceNames: fields.embeddedSourceNames ?? [],
|
|
152
|
+
blockingMetadata: fields.blockingMetadata ?? [],
|
|
153
|
+
referenceSymlinks: fields.referenceSymlinks ?? [],
|
|
154
|
+
// Rebuild inputs are source-owned configuration: they are retained, never a
|
|
155
|
+
// deletion candidate, and this module only ever reads them.
|
|
156
|
+
retainInputs: true,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- bounded, no-follow primitives ----
|
|
161
|
+
|
|
162
|
+
function marker(rel, present, detail) {
|
|
163
|
+
return { rel, present: present === true, detail: detail ?? null }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// readdir + Dirent types never follow a symbolic link, so a linked directory is reported
|
|
167
|
+
// as a link, not as the tree it points at.
|
|
168
|
+
function listDir(abs, maxEntries = MAX_MARKER_ENTRIES) {
|
|
169
|
+
let all
|
|
170
|
+
try { all = readdirSync(abs, { withFileTypes: true }) } catch (error) {
|
|
171
|
+
return { entries: [], truncated: false, error: error?.code ?? error?.message ?? 'unreadable' }
|
|
172
|
+
}
|
|
173
|
+
const sorted = all.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
174
|
+
return { entries: sorted.slice(0, maxEntries), truncated: sorted.length > maxEntries, error: null }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Opens with O_NOFOLLOW so a link swapped in after the lstat cannot redirect the read,
|
|
178
|
+
// hashes the opened descriptor, and refuses a file that changes under it.
|
|
179
|
+
function readBounded(path, expected, maxBytes) {
|
|
180
|
+
let fd = null
|
|
181
|
+
try {
|
|
182
|
+
fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW)
|
|
183
|
+
const opened = fstatSync(fd)
|
|
184
|
+
if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino) return { error: `${path} changed before it could be read` }
|
|
185
|
+
if (opened.size <= 0) return { error: `${path} is empty` }
|
|
186
|
+
if (opened.size > maxBytes) return { error: `${path} exceeds the ${maxBytes}-byte bound` }
|
|
187
|
+
const digest = createHash('sha256')
|
|
188
|
+
const chunks = []
|
|
189
|
+
const buffer = Buffer.allocUnsafe(HASH_CHUNK_BYTES)
|
|
190
|
+
let read = 0
|
|
191
|
+
while (read < opened.size) {
|
|
192
|
+
const got = readSync(fd, buffer, 0, Math.min(buffer.length, opened.size - read), read)
|
|
193
|
+
if (got <= 0) return { error: `${path} ended before its recorded size` }
|
|
194
|
+
digest.update(buffer.subarray(0, got))
|
|
195
|
+
chunks.push(Buffer.from(buffer.subarray(0, got)))
|
|
196
|
+
read += got
|
|
197
|
+
}
|
|
198
|
+
if (readSync(fd, Buffer.alloc(1), 0, 1, opened.size) !== 0) return { error: `${path} grew while it was being read` }
|
|
199
|
+
const final = fstatSync(fd)
|
|
200
|
+
if (!final.isFile() || final.dev !== opened.dev || final.ino !== opened.ino
|
|
201
|
+
|| final.size !== opened.size || final.mtimeMs !== opened.mtimeMs) {
|
|
202
|
+
return { error: `${path} changed while it was being read` }
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
buffer: Buffer.concat(chunks, read),
|
|
206
|
+
hash: digest.digest('hex'),
|
|
207
|
+
bytes: read,
|
|
208
|
+
identity: { dev: opened.dev, ino: opened.ino, size: opened.size, mtimeMs: opened.mtimeMs },
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return { error: `cannot read ${path}: ${error?.code ?? error?.message}` }
|
|
212
|
+
} finally {
|
|
213
|
+
if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- marker proof ----
|
|
218
|
+
|
|
219
|
+
function probeFile(root, rel, markers, { requireContent = false, maxBytes = null } = {}) {
|
|
220
|
+
let st
|
|
221
|
+
try { st = lstatSync(join(root, rel)) } catch (error) {
|
|
222
|
+
if (error?.code === 'ENOENT') { markers.push(marker(rel, false, 'missing')); return { ok: false, reason: `${rel} is missing` } }
|
|
223
|
+
markers.push(marker(rel, false, `unreadable: ${error?.code ?? error?.message}`))
|
|
224
|
+
return { ok: false, reason: `cannot inspect ${rel}: ${error?.code ?? error?.message}` }
|
|
225
|
+
}
|
|
226
|
+
if (st.isSymbolicLink()) { markers.push(marker(rel, true, 'symbolic link')); return { ok: false, reason: `${rel} is a symbolic link; a linked marker cannot prove the format` } }
|
|
227
|
+
if (!st.isFile()) { markers.push(marker(rel, true, 'not a regular file')); return { ok: false, reason: `${rel} is not a regular file` } }
|
|
228
|
+
if (requireContent && st.size === 0) { markers.push(marker(rel, true, 'empty')); return { ok: false, reason: `${rel} is empty` } }
|
|
229
|
+
if (maxBytes !== null && st.size > maxBytes) { markers.push(marker(rel, true, 'oversize')); return { ok: false, reason: `${rel} exceeds the ${maxBytes}-byte bound` } }
|
|
230
|
+
markers.push(marker(rel, true, null))
|
|
231
|
+
return { ok: true, stat: st }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function probeDirectory(root, rel, markers, { requireEntry = false } = {}) {
|
|
235
|
+
let st
|
|
236
|
+
try { st = lstatSync(join(root, rel)) } catch (error) {
|
|
237
|
+
if (error?.code === 'ENOENT') { markers.push(marker(rel, false, 'missing')); return { ok: false, reason: `${rel} is missing` } }
|
|
238
|
+
markers.push(marker(rel, false, `unreadable: ${error?.code ?? error?.message}`))
|
|
239
|
+
return { ok: false, reason: `cannot inspect ${rel}: ${error?.code ?? error?.message}` }
|
|
240
|
+
}
|
|
241
|
+
if (st.isSymbolicLink()) { markers.push(marker(rel, true, 'symbolic link')); return { ok: false, reason: `${rel} is a symbolic link; a linked directory cannot prove the format` } }
|
|
242
|
+
if (!st.isDirectory()) { markers.push(marker(rel, true, 'not a directory')); return { ok: false, reason: `${rel} is not a directory` } }
|
|
243
|
+
const listed = listDir(join(root, rel))
|
|
244
|
+
if (listed.error) {
|
|
245
|
+
markers.push(marker(rel, true, `unreadable: ${listed.error}`))
|
|
246
|
+
return { ok: false, reason: `cannot enumerate ${rel}: ${listed.error}` }
|
|
247
|
+
}
|
|
248
|
+
if (requireEntry && listed.entries.length === 0) { markers.push(marker(rel, true, 'empty')); return { ok: false, reason: `${rel} is empty` } }
|
|
249
|
+
markers.push(marker(rel, true, listed.truncated ? `listing bound of ${MAX_MARKER_ENTRIES} entries reached` : null))
|
|
250
|
+
return { ok: true, entries: listed.entries, truncated: listed.truncated }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function provePnpmInstall(root, markers) {
|
|
254
|
+
const modules = probeFile(root, '.modules.yaml', markers, { requireContent: true })
|
|
255
|
+
if (!modules.ok) return modules
|
|
256
|
+
const virtual = probeDirectory(root, '.pnpm', markers)
|
|
257
|
+
if (!virtual.ok) return virtual
|
|
258
|
+
// pnpm names each virtual-store entry <name>@<version> or @scope+<name>@<version>.
|
|
259
|
+
const installed = virtual.entries.find((entry) => entry.isDirectory() && entry.name.includes('@') && entry.name.length > 2)
|
|
260
|
+
markers.push(marker('.pnpm/<package@version>', Boolean(installed), installed ? installed.name : 'no installed package directory'))
|
|
261
|
+
if (!installed) {
|
|
262
|
+
return { ok: false, reason: `.pnpm holds no installed package directory${virtual.truncated ? ' within the listing bound' : ''}, so the install is not proven complete` }
|
|
263
|
+
}
|
|
264
|
+
return { ok: true }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// bin/python is expected to be a REFERENCE to the base interpreter. It is read as a link
|
|
268
|
+
// text and never resolved, opened, or stat'ed through.
|
|
269
|
+
function proveInterpreterReference(root, markers) {
|
|
270
|
+
const rel = 'bin/python'
|
|
271
|
+
const path = join(root, rel)
|
|
272
|
+
let st
|
|
273
|
+
try { st = lstatSync(path) } catch (error) {
|
|
274
|
+
if (error?.code === 'ENOENT') { markers.push(marker(rel, false, 'missing')); return { ok: false, reason: `${rel} is missing` } }
|
|
275
|
+
markers.push(marker(rel, false, `unreadable: ${error?.code ?? error?.message}`))
|
|
276
|
+
return { ok: false, reason: `cannot inspect ${rel}: ${error?.code ?? error?.message}` }
|
|
277
|
+
}
|
|
278
|
+
if (st.isSymbolicLink()) {
|
|
279
|
+
let target
|
|
280
|
+
try { target = readlinkSync(path) } catch (error) {
|
|
281
|
+
markers.push(marker(rel, true, 'unreadable link'))
|
|
282
|
+
return { ok: false, reason: `cannot read the interpreter reference ${rel}: ${error?.code ?? error?.message}` }
|
|
283
|
+
}
|
|
284
|
+
if (!nonEmpty(target)) {
|
|
285
|
+
markers.push(marker(rel, true, 'empty link'))
|
|
286
|
+
return { ok: false, reason: `${rel} is an empty symbolic link` }
|
|
287
|
+
}
|
|
288
|
+
markers.push(marker(rel, true, `interpreter reference -> ${target.length > 200 ? `${target.slice(0, 197)}...` : target}`))
|
|
289
|
+
return { ok: true, interpreter: { kind: 'reference', target } }
|
|
290
|
+
}
|
|
291
|
+
if (st.isFile() && st.size > 0) {
|
|
292
|
+
markers.push(marker(rel, true, 'interpreter copy'))
|
|
293
|
+
return { ok: true, interpreter: { kind: 'file' } }
|
|
294
|
+
}
|
|
295
|
+
markers.push(marker(rel, true, 'not an interpreter'))
|
|
296
|
+
return { ok: false, reason: `${rel} is neither an interpreter reference nor a non-empty interpreter file` }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function proveUvVenv(root, markers) {
|
|
300
|
+
const config = probeFile(root, 'pyvenv.cfg', markers, { requireContent: true })
|
|
301
|
+
if (!config.ok) return config
|
|
302
|
+
const bin = probeDirectory(root, 'bin', markers)
|
|
303
|
+
if (!bin.ok) return bin
|
|
304
|
+
const interpreter = proveInterpreterReference(root, markers)
|
|
305
|
+
if (!interpreter.ok) return interpreter
|
|
306
|
+
const lib = probeDirectory(root, 'lib', markers)
|
|
307
|
+
if (!lib.ok) return lib
|
|
308
|
+
const pythonDir = lib.entries.find((entry) => entry.isDirectory() && /^python\d/i.test(entry.name))
|
|
309
|
+
markers.push(marker('lib/python*/', Boolean(pythonDir), pythonDir ? pythonDir.name : 'no python directory'))
|
|
310
|
+
if (!pythonDir) return { ok: false, reason: 'lib holds no python<version> directory' }
|
|
311
|
+
const sitePackages = probeDirectory(root, `lib/${pythonDir.name}/site-packages`, markers)
|
|
312
|
+
if (!sitePackages.ok) return sitePackages
|
|
313
|
+
return { ok: true, interpreter: interpreter.interpreter }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function proveNextBuild(root, markers) {
|
|
317
|
+
const buildId = probeFile(root, 'BUILD_ID', markers, { requireContent: true })
|
|
318
|
+
if (!buildId.ok) return buildId
|
|
319
|
+
const manifest = probeFile(root, 'build-manifest.json', markers, { requireContent: true, maxBytes: MAX_MANIFEST_BYTES })
|
|
320
|
+
if (!manifest.ok) return manifest
|
|
321
|
+
const read = readBounded(join(root, 'build-manifest.json'), manifest.stat, MAX_MANIFEST_BYTES)
|
|
322
|
+
if (read.error) return { ok: false, reason: read.error }
|
|
323
|
+
let parsed
|
|
324
|
+
try { parsed = JSON.parse(read.buffer.toString('utf8')) } catch { return { ok: false, reason: 'build-manifest.json is not readable JSON' } }
|
|
325
|
+
if (!isObject(parsed)) return { ok: false, reason: 'build-manifest.json does not contain a JSON object' }
|
|
326
|
+
const server = probeDirectory(root, 'server', markers, { requireEntry: true })
|
|
327
|
+
if (!server.ok) return server
|
|
328
|
+
return { ok: true }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// A content-addressed name is a long hex digest, optionally with pnpm's -index.json or
|
|
332
|
+
// executable suffix. Bucket scanning is capped so a large store cannot turn this into a
|
|
333
|
+
// walk; a store whose first bounded buckets hold no file stays unproven.
|
|
334
|
+
function findContentAddressedEntry(dir, entries) {
|
|
335
|
+
let scanned = 0
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
if (entry.isFile() && CA_FILE_NAME.test(entry.name)) return entry.name
|
|
338
|
+
if (!entry.isDirectory() || !/^[0-9a-f]{2}$/i.test(entry.name)) continue
|
|
339
|
+
if (++scanned > MAX_BUCKETS_SCANNED) return null
|
|
340
|
+
const bucket = listDir(join(dir, entry.name), MAX_BUCKET_ENTRIES)
|
|
341
|
+
if (bucket.error) continue
|
|
342
|
+
const hit = bucket.entries.find((child) => child.isFile() && CA_FILE_NAME.test(child.name))
|
|
343
|
+
if (hit) return `${entry.name}/${hit.name}`
|
|
344
|
+
}
|
|
345
|
+
return null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function provePnpmStore(root, markers) {
|
|
349
|
+
const listing = listDir(root)
|
|
350
|
+
if (listing.error) return { ok: false, reason: `cannot enumerate the store root: ${listing.error}` }
|
|
351
|
+
const versions = listing.entries
|
|
352
|
+
.filter((entry) => entry.isDirectory() && /^v\d+$/.test(entry.name))
|
|
353
|
+
.map((entry) => entry.name)
|
|
354
|
+
.sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)) || (a < b ? 1 : a > b ? -1 : 0))
|
|
355
|
+
if (!versions.length) return { ok: false, reason: 'no versioned v<N> store directory at the root' }
|
|
356
|
+
const versionDir = versions[0]
|
|
357
|
+
markers.push(marker(versionDir, true, `store version ${versionDir.slice(1)}`))
|
|
358
|
+
const files = probeDirectory(root, `${versionDir}/files`, markers)
|
|
359
|
+
if (!files.ok) return files
|
|
360
|
+
const sample = findContentAddressedEntry(join(root, versionDir, 'files'), files.entries)
|
|
361
|
+
markers.push(marker(`${versionDir}/files/<content-addressed>`, Boolean(sample), sample ?? (files.truncated ? `listing bound of ${MAX_MARKER_ENTRIES} entries reached` : 'no content-addressed file')))
|
|
362
|
+
if (!sample) return { ok: false, reason: `${versionDir}/files holds no content-addressed file` }
|
|
363
|
+
return { ok: true }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const PROVERS = {
|
|
367
|
+
'pnpm-install': provePnpmInstall,
|
|
368
|
+
'uv-venv': proveUvVenv,
|
|
369
|
+
'next-build': proveNextBuild,
|
|
370
|
+
'pnpm-store': provePnpmStore,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function proveMarkers(format, target) {
|
|
374
|
+
const root = resolve(target)
|
|
375
|
+
let st
|
|
376
|
+
try { st = lstatSync(root) } catch (error) {
|
|
377
|
+
if (error?.code === 'ENOENT') return { ok: false, state: 'missing', reason: `${root} does not exist`, markers: [marker('.', false, 'missing')], root }
|
|
378
|
+
return { ok: false, state: 'partial', reason: `cannot inspect ${root}: ${error?.code ?? error?.message}`, markers: [marker('.', false, 'unreadable')], root }
|
|
379
|
+
}
|
|
380
|
+
if (st.isSymbolicLink()) return { ok: false, state: 'partial', reason: `${root} is a symbolic link; a linked root cannot prove the format`, markers: [marker('.', true, 'symbolic link')], root }
|
|
381
|
+
if (!st.isDirectory()) return { ok: false, state: 'partial', reason: `${root} is not a directory`, markers: [marker('.', true, 'not a directory')], root }
|
|
382
|
+
let rootReal
|
|
383
|
+
try { rootReal = real(root) } catch (error) {
|
|
384
|
+
return { ok: false, state: 'partial', reason: `cannot resolve ${root}: ${error?.code ?? error?.message}`, markers: [marker('.', true, 'unresolvable')], root }
|
|
385
|
+
}
|
|
386
|
+
const listing = listDir(root)
|
|
387
|
+
if (listing.error) return { ok: false, state: 'partial', reason: `cannot enumerate ${root}: ${listing.error}`, markers: [marker('.', true, 'unreadable')], root, rootReal }
|
|
388
|
+
const markers = []
|
|
389
|
+
const result = PROVERS[format](root, markers)
|
|
390
|
+
if (result.ok) return { ok: true, state: 'ready', reason: null, markers, root, rootReal, interpreter: result.interpreter ?? null }
|
|
391
|
+
// A root that still holds something is a partial root: it looks present after a manual
|
|
392
|
+
// cleanup but nothing here proves it usable. An empty root with no marker is missing.
|
|
393
|
+
const present = markers.some((entry) => entry.present)
|
|
394
|
+
const state = present || listing.entries.length > 0 ? 'partial' : 'missing'
|
|
395
|
+
return { ok: false, state, reason: result.reason, markers, root, rootReal }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- rebuild input proof ----
|
|
399
|
+
|
|
400
|
+
function flattenInputs(inputs) {
|
|
401
|
+
if (Array.isArray(inputs)) return inputs
|
|
402
|
+
if (isObject(inputs)) return Object.values(inputs)
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function proveInput({ name, path, role, parseJson, root }) {
|
|
407
|
+
let st
|
|
408
|
+
try { st = lstatSync(path) } catch (error) {
|
|
409
|
+
if (error?.code === 'ENOENT') return { error: `rebuild input ${path} is missing` }
|
|
410
|
+
return { error: `cannot inspect rebuild input ${path}: ${error?.code ?? error?.message}` }
|
|
411
|
+
}
|
|
412
|
+
if (st.isSymbolicLink()) return { error: `rebuild input ${path} is a symbolic link; a link is not a proven source` }
|
|
413
|
+
if (!st.isFile()) return { error: `rebuild input ${path} is not a regular file` }
|
|
414
|
+
if (st.nlink !== 1) return { error: `rebuild input ${path} has ${st.nlink} hard links; the file on disk is ambiguous` }
|
|
415
|
+
if (st.size <= 0) return { error: `rebuild input ${path} is empty` }
|
|
416
|
+
if (st.size > MAX_REBUILD_INPUT_BYTES) return { error: `rebuild input ${path} exceeds the ${MAX_REBUILD_INPUT_BYTES}-byte bound` }
|
|
417
|
+
let realPath
|
|
418
|
+
try { realPath = real(path) } catch (error) {
|
|
419
|
+
return { error: `cannot resolve rebuild input ${path}: ${error?.code ?? error?.message}` }
|
|
420
|
+
}
|
|
421
|
+
if (containsPath(root, realPath) || normPath(realPath) === normPath(root)) {
|
|
422
|
+
return { error: `rebuild input ${path} resolves inside the resource target; a rebuild input must sit outside it` }
|
|
423
|
+
}
|
|
424
|
+
const read = readBounded(path, st, MAX_REBUILD_INPUT_BYTES)
|
|
425
|
+
if (read.error) return { error: read.error }
|
|
426
|
+
let parsed = false
|
|
427
|
+
if (parseJson) {
|
|
428
|
+
let value
|
|
429
|
+
try { value = JSON.parse(read.buffer.toString('utf8')) } catch { return { error: `rebuild input ${path} is not readable JSON` } }
|
|
430
|
+
if (!isObject(value)) return { error: `rebuild input ${path} does not contain a JSON object` }
|
|
431
|
+
parsed = true
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
value: {
|
|
435
|
+
name,
|
|
436
|
+
path,
|
|
437
|
+
realPath,
|
|
438
|
+
role,
|
|
439
|
+
sourceOwned: true,
|
|
440
|
+
identity: read.identity,
|
|
441
|
+
hash: read.hash,
|
|
442
|
+
bytes: read.bytes,
|
|
443
|
+
parsedJson: parsed,
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function proveRebuildInputs(format, root, inputs) {
|
|
449
|
+
const spec = FORMATS[format]
|
|
450
|
+
const given = flattenInputs(inputs)
|
|
451
|
+
if (!given) return { ok: false, reason: `explicit canonical rebuild inputs are required to prove ${format}`, proofs: [] }
|
|
452
|
+
if (given.length > MAX_REBUILD_INPUTS) return { ok: false, reason: `${format} accepts at most ${MAX_REBUILD_INPUTS} rebuild inputs`, proofs: [] }
|
|
453
|
+
const byName = new Map()
|
|
454
|
+
for (const value of given) {
|
|
455
|
+
if (!nonEmpty(value) || !isAbsolute(value)) return { ok: false, reason: `rebuild input ${JSON.stringify(value)} must be an absolute path`, proofs: [] }
|
|
456
|
+
const name = basename(value)
|
|
457
|
+
if (!spec.inputs.includes(name)) return { ok: false, reason: `${name} is not a canonical rebuild input for ${format} (expected ${spec.inputs.join(' + ')})`, proofs: [] }
|
|
458
|
+
if (byName.has(name)) return { ok: false, reason: `rebuild input ${name} was supplied more than once`, proofs: [] }
|
|
459
|
+
byName.set(name, resolve(value))
|
|
460
|
+
}
|
|
461
|
+
const missing = spec.inputs.filter((name) => !byName.has(name))
|
|
462
|
+
if (missing.length) return { ok: false, reason: `missing canonical rebuild input(s) for ${format}: ${missing.join(', ')}`, proofs: [] }
|
|
463
|
+
const proofs = []
|
|
464
|
+
for (const name of spec.inputs) {
|
|
465
|
+
const path = byName.get(name)
|
|
466
|
+
const proof = proveInput({ name, path, root, role: name.includes('lock') ? 'lockfile' : 'manifest', parseJson: spec.jsonInputs.includes(name) })
|
|
467
|
+
if (proof.error) return { ok: false, reason: proof.error, proofs: [] }
|
|
468
|
+
proofs.push(proof.value)
|
|
469
|
+
}
|
|
470
|
+
return { ok: true, reason: null, proofs }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---- public API ----
|
|
474
|
+
|
|
475
|
+
export function classifyEmbeddedNames({ format, names } = {}) {
|
|
476
|
+
const resolved = formatId(format)
|
|
477
|
+
const typed = resolved !== 'generic'
|
|
478
|
+
const allowed = []
|
|
479
|
+
const blocking = []
|
|
480
|
+
for (const name of Array.isArray(names) ? names : []) {
|
|
481
|
+
if (!nonEmpty(name)) continue
|
|
482
|
+
const base = basename(name)
|
|
483
|
+
if (typed && VENDORED_SOURCE_NAMES.has(base)) { allowed.push(name); continue }
|
|
484
|
+
// A vendored name blocks for a generic resource: only a proven typed format may
|
|
485
|
+
// embed one, so a folder name never grants itself authority.
|
|
486
|
+
if (RUN_METADATA_NAMES.has(base) || VENDORED_SOURCE_NAMES.has(base)) blocking.push(name)
|
|
487
|
+
}
|
|
488
|
+
return { format: resolved, allowEmbeddedSourceNames: typed, allowed, blocking }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function validateRebuildInputs(inputProofs) {
|
|
492
|
+
if (!Array.isArray(inputProofs) || inputProofs.length === 0) {
|
|
493
|
+
return { ok: false, reason: 'no rebuild input proof was recorded, so the artifact cannot be proven reproducible', checked: 0, changed: [] }
|
|
494
|
+
}
|
|
495
|
+
if (inputProofs.length > MAX_REBUILD_INPUTS) {
|
|
496
|
+
return { ok: false, reason: `more than ${MAX_REBUILD_INPUTS} rebuild input proofs were recorded`, checked: 0, changed: [] }
|
|
497
|
+
}
|
|
498
|
+
const changed = []
|
|
499
|
+
for (const proof of inputProofs) {
|
|
500
|
+
const name = isObject(proof) && nonEmpty(proof.name) ? proof.name : '(unnamed)'
|
|
501
|
+
const path = isObject(proof) && nonEmpty(proof.path) ? proof.path : null
|
|
502
|
+
const record = (kind, detail) => changed.push({ name, path, kind, detail })
|
|
503
|
+
if (!isObject(proof) || !nonEmpty(proof.path) || !nonEmpty(proof.hash) || !isObject(proof.identity)) {
|
|
504
|
+
record('unproven', 'the recorded proof is incomplete')
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
let st
|
|
508
|
+
try { st = lstatSync(proof.path) } catch (error) {
|
|
509
|
+
record(error?.code === 'ENOENT' ? 'missing' : 'unreadable', error?.code ?? error?.message ?? 'unreadable')
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
if (st.isSymbolicLink()) { record('path', 'the input is now a symbolic link'); continue }
|
|
513
|
+
if (!st.isFile()) { record('path', 'the input is no longer a regular file'); continue }
|
|
514
|
+
if (st.nlink !== 1) { record('hardlink', `the input now has ${st.nlink} hard links`); continue }
|
|
515
|
+
let realPath
|
|
516
|
+
try { realPath = real(proof.path) } catch (error) {
|
|
517
|
+
record('unreadable', `cannot resolve the input: ${error?.code ?? error?.message}`)
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
if (nonEmpty(proof.realPath) && normPath(realPath) !== normPath(proof.realPath)) {
|
|
521
|
+
record('path', `the input now resolves to ${realPath}`)
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
if (st.dev !== proof.identity.dev || st.ino !== proof.identity.ino) {
|
|
525
|
+
record('identity', 'the input was replaced by a different file')
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
if (st.size !== proof.identity.size) { record('content', `the input size changed from ${proof.identity.size} to ${st.size}`); continue }
|
|
529
|
+
if (st.size > MAX_REBUILD_INPUT_BYTES) { record('content', `the input exceeds the ${MAX_REBUILD_INPUT_BYTES}-byte bound`); continue }
|
|
530
|
+
const read = readBounded(proof.path, st, MAX_REBUILD_INPUT_BYTES)
|
|
531
|
+
if (read.error) { record('unreadable', read.error); continue }
|
|
532
|
+
if (read.hash !== proof.hash) record('content', 'the input content changed')
|
|
533
|
+
}
|
|
534
|
+
if (changed.length) {
|
|
535
|
+
return { ok: false, reason: `${changed.length} rebuild input(s) no longer match the recorded proof: ${changed.map((entry) => `${entry.name} (${entry.kind})`).join(', ')}`, checked: inputProofs.length, changed }
|
|
536
|
+
}
|
|
537
|
+
return { ok: true, reason: null, checked: inputProofs.length, changed: [] }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function readReferenceSymlinks(inspection) {
|
|
541
|
+
const list = Array.isArray(inspection?.externalSymlinks) ? inspection.externalSymlinks : []
|
|
542
|
+
return list
|
|
543
|
+
.filter((entry) => isObject(entry) && nonEmpty(entry.path))
|
|
544
|
+
.map((entry) => ({ path: entry.path, target: nonEmpty(entry.target) ? entry.target : null }))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// The caller's own tree inspection decides the name verdict, so a typed format can only
|
|
548
|
+
// relax the guard when that inspection finished and found no run metadata.
|
|
549
|
+
function inspectionBlock(format, inspection) {
|
|
550
|
+
const names = classifyEmbeddedNames({ format, names: inspection?.metadataNames })
|
|
551
|
+
if (inspection && inspection.ok !== true) {
|
|
552
|
+
return { blocked: true, names, reason: `the containing tree inspection did not finish (${inspection.reason ?? 'no reason given'}), so the format cannot be proven complete` }
|
|
553
|
+
}
|
|
554
|
+
if (names.blocking.length) {
|
|
555
|
+
return { blocked: true, names, reason: `run metadata inside the target is never disposable and a typed format does not change that: ${names.blocking.slice(0, 3).join(', ')}` }
|
|
556
|
+
}
|
|
557
|
+
return { blocked: false, names, reason: null }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function inspectResourceFormat(options = {}) {
|
|
561
|
+
const requested = options?.format
|
|
562
|
+
const format = formatId(requested)
|
|
563
|
+
if (format === 'generic') return verdict({ ok: false, state: 'unproven', reason: genericReason(requested) })
|
|
564
|
+
if (!nonEmpty(options?.path)) return verdict({ ok: false, state: 'unproven', format, reason: `${format} needs the resource path it must prove` })
|
|
565
|
+
|
|
566
|
+
const markerProof = proveMarkers(format, options.path)
|
|
567
|
+
const inspection = inspectionBlock(format, options?.inspection)
|
|
568
|
+
const referenceSymlinks = readReferenceSymlinks(options?.inspection)
|
|
569
|
+
const base = {
|
|
570
|
+
format,
|
|
571
|
+
root: markerProof.rootReal ?? markerProof.root,
|
|
572
|
+
markers: markerProof.markers,
|
|
573
|
+
embeddedSourceNames: inspection.names.allowed,
|
|
574
|
+
blockingMetadata: inspection.names.blocking,
|
|
575
|
+
referenceSymlinks,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!markerProof.ok) return verdict({ ...base, ok: false, state: markerProof.state, reason: markerProof.reason })
|
|
579
|
+
if (inspection.blocked) return verdict({ ...base, ok: false, state: 'partial', reason: inspection.reason })
|
|
580
|
+
const inputs = proveRebuildInputs(format, markerProof.rootReal ?? markerProof.root, options.inputs)
|
|
581
|
+
if (!inputs.ok) {
|
|
582
|
+
return verdict({ ...base, ok: false, state: 'inputs-changed', reason: inputs.reason, inputProofs: null })
|
|
583
|
+
}
|
|
584
|
+
return verdict({
|
|
585
|
+
...base,
|
|
586
|
+
ok: true,
|
|
587
|
+
state: 'ready',
|
|
588
|
+
allowEmbeddedSourceNames: true,
|
|
589
|
+
allowReferenceSymlinks: true,
|
|
590
|
+
inputProofs: inputs.proofs,
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function classifyRebuildState(options = {}) {
|
|
595
|
+
const requested = options?.format
|
|
596
|
+
const format = formatId(requested)
|
|
597
|
+
if (format === 'generic') return { ok: false, state: 'unproven', reason: genericReason(requested), format: 'generic', root: null, markers: [], inputCheck: null }
|
|
598
|
+
if (!nonEmpty(options?.path)) return { ok: false, state: 'unproven', reason: `${format} needs the resource path it must classify`, format, root: null, markers: [], inputCheck: null }
|
|
599
|
+
|
|
600
|
+
const markerProof = proveMarkers(format, options.path)
|
|
601
|
+
const inspection = inspectionBlock(format, options?.inspection)
|
|
602
|
+
const result = { format, root: markerProof.rootReal ?? markerProof.root, markers: markerProof.markers, blockingMetadata: inspection.names.blocking, inputCheck: null }
|
|
603
|
+
if (!markerProof.ok) return { ok: false, state: markerProof.state, reason: markerProof.reason, ...result }
|
|
604
|
+
if (inspection.blocked) return { ok: false, state: 'partial', reason: inspection.reason, ...result }
|
|
605
|
+
|
|
606
|
+
const required = FORMATS[format].inputs
|
|
607
|
+
const recorded = Array.isArray(options.inputProofs) ? options.inputProofs : []
|
|
608
|
+
const names = recorded.map((proof) => (isObject(proof) && nonEmpty(proof.name) ? proof.name : null))
|
|
609
|
+
const namesMatch = names.length === required.length && required.every((name) => names.includes(name))
|
|
610
|
+
if (!namesMatch) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
state: 'inputs-changed',
|
|
614
|
+
reason: `the recorded rebuild input proof does not match the canonical ${format} inputs (expected ${required.join(' + ')})`,
|
|
615
|
+
...result,
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Persisted labels are not authority: recheck each actual path's canonical
|
|
619
|
+
// input name and its location outside the resource before trusting its hash.
|
|
620
|
+
const fresh = proveRebuildInputs(format, result.root, recorded.map((proof) => proof?.path))
|
|
621
|
+
if (!fresh.ok) return { ok: false, state: 'inputs-changed', reason: fresh.reason, ...result }
|
|
622
|
+
const check = validateRebuildInputs(recorded)
|
|
623
|
+
if (!check.ok) return { ok: false, state: 'inputs-changed', reason: check.reason, ...result, inputCheck: check }
|
|
624
|
+
return { ok: true, state: 'ready', reason: null, ...result, inputCheck: check }
|
|
625
|
+
}
|