@nextcommerce/campaigns-os 1.60.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Element parity probe: one read-only isolated-world read per side of every
|
|
2
|
+
// visual cell, run through C's runVisualPass probe hook (RC-1 to RC-4). It
|
|
3
|
+
// reads text-bearing elements (readability's rule, through the shared
|
|
4
|
+
// contrast helper) and returns a ProbeOutput (stream P contract Appendix A).
|
|
5
|
+
// The output is written only to the local probe files (src/parity/snapshot.mjs),
|
|
6
|
+
// never to the report.
|
|
7
|
+
import { contrastToolkit } from "../contrast.mjs";
|
|
8
|
+
import { PARITY_MAX_ROUTES, PARITY_PROBE_CELL_MS, PARITY_PROBE_RUN_MS, PARITY_PROBE_WALK_MS } from "../polish-deadline.mjs";
|
|
9
|
+
import { PARITY_PROBE_LIMITS } from "./compare.mjs";
|
|
10
|
+
|
|
11
|
+
export const PARITY_PROBE_NAME = "element_parity";
|
|
12
|
+
export { PARITY_PROBE_VERSION } from "./element-parity-record.mjs";
|
|
13
|
+
export const PARITY_PROBE_LIMITS_ARG = Object.freeze({ ...PARITY_PROBE_LIMITS, walk_ms: PARITY_PROBE_WALK_MS });
|
|
14
|
+
|
|
15
|
+
// The page-side read. It never clicks, focuses, hovers, scrolls, types or
|
|
16
|
+
// writes, and it never enters a frame's document or a closed shadow root:
|
|
17
|
+
// those are counted in `coverage`. `closedRoots` are the closed shadow roots
|
|
18
|
+
// the hook resolved into this world (RC-3).
|
|
19
|
+
//
|
|
20
|
+
// - page_text is assembled before element measurement:
|
|
21
|
+
// body.innerText, then for each open shadow root in walk order a space and
|
|
22
|
+
// the textContent of each of its child elements other than style, script
|
|
23
|
+
// and template; whitespace-collapsed and cut at limits.page_text_chars.
|
|
24
|
+
// If traversal exhausts the clock first, unread text is marked truncated.
|
|
25
|
+
// - The walk covers the document and every open shadow root, entered right
|
|
26
|
+
// after its host, and stops at limits.elements text-bearing elements or
|
|
27
|
+
// once limits.walk_ms has passed since the probe started. A selector path
|
|
28
|
+
// is cut at limits.selector_path_chars code points (shared with the comparator).
|
|
29
|
+
// - font_faces are document.fonts in iteration order, at most limits.font_faces.
|
|
30
|
+
export function parityProbe(toolkit, limits, closedRoots) {
|
|
31
|
+
const started = performance.now();
|
|
32
|
+
let timeCapped = false;
|
|
33
|
+
const outOfTime = () => {
|
|
34
|
+
if (performance.now() - started >= limits.walk_ms) timeCapped = true;
|
|
35
|
+
return timeCapped;
|
|
36
|
+
};
|
|
37
|
+
const win = window;
|
|
38
|
+
const doc = document;
|
|
39
|
+
const collapse = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
|
|
40
|
+
// [the first `max` code points, whether anything was cut]
|
|
41
|
+
const cut = (value, max) => {
|
|
42
|
+
let count = 0;
|
|
43
|
+
let index = 0;
|
|
44
|
+
for (const char of value) {
|
|
45
|
+
if (count === max) return [value.slice(0, index), true];
|
|
46
|
+
count += 1;
|
|
47
|
+
index += char.length;
|
|
48
|
+
}
|
|
49
|
+
return [value, false];
|
|
50
|
+
};
|
|
51
|
+
const inOrder = [];
|
|
52
|
+
const openRoots = [];
|
|
53
|
+
const enter = (top) => {
|
|
54
|
+
for (const el of top) {
|
|
55
|
+
if (outOfTime()) return;
|
|
56
|
+
inOrder.push(el);
|
|
57
|
+
if (el.shadowRoot) {
|
|
58
|
+
openRoots.push(el.shadowRoot);
|
|
59
|
+
enter(el.shadowRoot.querySelectorAll("*"));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
if (doc.body) enter([doc.body, ...doc.body.querySelectorAll("*")]);
|
|
64
|
+
|
|
65
|
+
let textIncomplete = outOfTime();
|
|
66
|
+
let rawText = doc.body && !textIncomplete ? doc.body.innerText : "";
|
|
67
|
+
for (const root of openRoots) {
|
|
68
|
+
for (const child of root.children) {
|
|
69
|
+
if (outOfTime()) { textIncomplete = true; break; }
|
|
70
|
+
if (!["style", "script", "template"].includes(child.localName)) rawText += ` ${child.textContent}`;
|
|
71
|
+
}
|
|
72
|
+
if (timeCapped) break;
|
|
73
|
+
}
|
|
74
|
+
const [pageText, pageTextTruncated] = cut(collapse(rawText), limits.page_text_chars);
|
|
75
|
+
|
|
76
|
+
const fontFaces = [];
|
|
77
|
+
let fontFacesCapped = false;
|
|
78
|
+
for (const face of doc.fonts) {
|
|
79
|
+
if (outOfTime()) { fontFacesCapped = true; break; }
|
|
80
|
+
if (fontFaces.length >= limits.font_faces) {
|
|
81
|
+
fontFacesCapped = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
fontFaces.push({ family: String(face.family).replace(/^(["'])(.*)\1$/s, "$2"), status: face.status });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let framesNotRead = 0;
|
|
88
|
+
for (const el of inOrder) {
|
|
89
|
+
if (outOfTime()) break;
|
|
90
|
+
if ((el.localName === "iframe" || el.localName === "frame") && toolkit.isVisible(el)) framesNotRead += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const round1 = (value) => Math.round(value * 10) / 10;
|
|
94
|
+
const length = (value) => (value === "normal" ? "normal" : Number.parseFloat(value));
|
|
95
|
+
const directText = (el) => (el.localName === "input"
|
|
96
|
+
? el.value
|
|
97
|
+
: Array.from(el.childNodes).filter((node) => node.nodeType === 3).map((node) => node.data).join(""));
|
|
98
|
+
const subtreeText = (el) => (el.localName === "input" ? el.value : typeof el.innerText === "string" ? el.innerText : el.textContent);
|
|
99
|
+
const read = (el, index) => {
|
|
100
|
+
const rect = el.getBoundingClientRect();
|
|
101
|
+
const style = win.getComputedStyle(el);
|
|
102
|
+
const measured = toolkit.measureTextElement(el, win);
|
|
103
|
+
return {
|
|
104
|
+
index,
|
|
105
|
+
selector_path: cut(toolkit.selectorPath(el), limits.selector_path_chars)[0],
|
|
106
|
+
text: cut(collapse(directText(el)), limits.text_chars)[0],
|
|
107
|
+
ftext: cut(collapse(subtreeText(el)), limits.text_chars)[0],
|
|
108
|
+
box: { x: round1(rect.left + win.scrollX), y: round1(rect.top + win.scrollY), w: round1(rect.width), h: round1(rect.height) },
|
|
109
|
+
font_family: style.getPropertyValue("font-family"),
|
|
110
|
+
font_size_px: Number.parseFloat(style.getPropertyValue("font-size")),
|
|
111
|
+
font_weight: Number(style.getPropertyValue("font-weight")),
|
|
112
|
+
font_style: style.getPropertyValue("font-style"),
|
|
113
|
+
line_height: length(style.getPropertyValue("line-height")),
|
|
114
|
+
letter_spacing: length(style.getPropertyValue("letter-spacing")),
|
|
115
|
+
text_transform: style.getPropertyValue("text-transform"),
|
|
116
|
+
fg_srgb: measured.fg_srgb,
|
|
117
|
+
bg_srgb: measured.bg_srgb,
|
|
118
|
+
color_review: measured.review_reason ?? null,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
const elements = [];
|
|
122
|
+
let elementsCapped = false;
|
|
123
|
+
for (const el of inOrder) {
|
|
124
|
+
if (outOfTime()) break;
|
|
125
|
+
if (!toolkit.isTextBearing(el, win)) continue;
|
|
126
|
+
// Visibility/text classification can itself trigger layout. Do not start
|
|
127
|
+
// a measurement after it has consumed the remaining budget.
|
|
128
|
+
if (outOfTime()) break;
|
|
129
|
+
if (elements.length >= limits.elements) {
|
|
130
|
+
elementsCapped = true;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
elements.push(read(el, elements.length));
|
|
134
|
+
if (outOfTime()) break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const closedShadowRoots = new Set(closedRoots.filter((root) => root && root.nodeType === 11)).size;
|
|
138
|
+
|
|
139
|
+
const output = {
|
|
140
|
+
viewport: { width: win.innerWidth, height: win.innerHeight },
|
|
141
|
+
dpr: win.devicePixelRatio,
|
|
142
|
+
elements,
|
|
143
|
+
page_text: pageText,
|
|
144
|
+
font_faces: fontFaces,
|
|
145
|
+
coverage: {
|
|
146
|
+
elements_capped: elementsCapped,
|
|
147
|
+
time_capped: timeCapped,
|
|
148
|
+
page_text_truncated: pageTextTruncated || textIncomplete,
|
|
149
|
+
font_faces_capped: fontFacesCapped,
|
|
150
|
+
closed_shadow_roots: closedShadowRoots,
|
|
151
|
+
frames_not_read: framesNotRead,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
// Account for the final element, viewport reads and coverage bookkeeping.
|
|
155
|
+
output.coverage.time_capped = outOfTime();
|
|
156
|
+
return output;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The function declaration the hook calls in the isolated world: the probe,
|
|
160
|
+
// composed with the shared contrast helper by source text, as
|
|
161
|
+
// readabilityProbeSource does.
|
|
162
|
+
export function parityProbeSource() {
|
|
163
|
+
return `function (limits, ...closedRoots) { return (${parityProbe.toString()})((${contrastToolkit.toString()})(), limits, closedRoots); }`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const cellKey = (side, route, viewport) => JSON.stringify([side, route, viewport]);
|
|
167
|
+
|
|
168
|
+
// One run's probe entry for runVisualPass({ probes }) and what it observed.
|
|
169
|
+
// The grid is the first `maxRoutes` plan routes; `select` refuses every other
|
|
170
|
+
// route's cells, and every cell once the elapsed_ms of the run's parity probes
|
|
171
|
+
// reaches `runMs` (those cells are remembered as run_budget_exhausted).
|
|
172
|
+
// `onResult` keeps each side's result by (side, route, viewport).
|
|
173
|
+
export function createParityProbeRun({ routes, runMs = PARITY_PROBE_RUN_MS, maxRoutes = PARITY_MAX_ROUTES } = {}) {
|
|
174
|
+
const captured = new Set((Array.isArray(routes) ? routes : []).slice(0, maxRoutes).map((route) => route.requested_route));
|
|
175
|
+
const results = new Map();
|
|
176
|
+
const exhausted = new Set();
|
|
177
|
+
let spent = 0;
|
|
178
|
+
const probe = Object.freeze({
|
|
179
|
+
name: PARITY_PROBE_NAME,
|
|
180
|
+
source: parityProbeSource(),
|
|
181
|
+
args: [PARITY_PROBE_LIMITS_ARG],
|
|
182
|
+
boundMs: PARITY_PROBE_CELL_MS,
|
|
183
|
+
closedRoots: true,
|
|
184
|
+
select(cell) {
|
|
185
|
+
if (!captured.has(cell?.route)) return false;
|
|
186
|
+
if (spent >= runMs) {
|
|
187
|
+
exhausted.add(cellKey(cell.side, cell.route, cell.viewport));
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
onResult(cell, result) {
|
|
193
|
+
if (!captured.has(cell?.route)) return;
|
|
194
|
+
const elapsed = result?.elapsed_ms;
|
|
195
|
+
spent += Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : PARITY_PROBE_CELL_MS;
|
|
196
|
+
results.set(cellKey(cell.side, cell.route, cell.viewport), result);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
probe,
|
|
201
|
+
spentMs: () => spent,
|
|
202
|
+
result: (side, route, viewport) => results.get(cellKey(side, route, viewport)) ?? null,
|
|
203
|
+
exhausted: (side, route, viewport) => exhausted.has(cellKey(side, route, viewport)),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Element parity probe files: canonical JSON under
|
|
2
|
+
// .campaign-runtime/polish/parity/, named by the sha256 of their bytes (the
|
|
3
|
+
// writeReadabilityCrop pattern), and re-hashed on every read. The directory
|
|
4
|
+
// ignores itself: before its first file the writer creates its .gitignore
|
|
5
|
+
// (`*`) with exclusive create and never overwrites one that exists, as C's
|
|
6
|
+
// capture writer does for captures/ and diff/. Files carry no timestamp, so an
|
|
7
|
+
// unchanged probe output keeps its content address across runs.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { canonicalJson } from "../polish-capture.mjs";
|
|
13
|
+
import { containedWritePath } from "../polish-visual.mjs";
|
|
14
|
+
import { readRegularFileSync } from "../source-html-server.mjs";
|
|
15
|
+
|
|
16
|
+
export const PARITY_DIR = ".campaign-runtime/polish/parity";
|
|
17
|
+
// Above every bounded ProbeOutput (1 500 elements of capped text, 256 Ki code
|
|
18
|
+
// points of page text) by a wide margin.
|
|
19
|
+
export const PARITY_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const FILE_PATH = /^\.campaign-runtime\/polish\/parity\/([a-f0-9]{64})\.json$/;
|
|
21
|
+
const FILE_SHA = /^sha256:([a-f0-9]{64})$/;
|
|
22
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
23
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
|
|
25
|
+
// Check every existing ancestor against the target before treating parity as
|
|
26
|
+
// a read root. The shared reader then opens, fstats and verifies the file's
|
|
27
|
+
// contained realpath/inode before reading any bytes (C A-C3).
|
|
28
|
+
function parityDirectory(targetRepo) {
|
|
29
|
+
const root = realpathSync(targetRepo);
|
|
30
|
+
return containedWritePath(root, PARITY_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readProbeBytes(ref, targetRepo, maxBytes = PARITY_FILE_MAX_BYTES) {
|
|
34
|
+
const directory = parityDirectory(targetRepo);
|
|
35
|
+
return readRegularFileSync(resolve(directory, `${ref.sha256.slice(7)}.json`), { maxBytes, root: directory, noFollow: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A probe file reference names its file by the sha256 of its bytes.
|
|
39
|
+
export function parityRefShapeOk(ref) {
|
|
40
|
+
if (!isPlainObject(ref) || Object.keys(ref).length !== 2 || typeof ref.path !== "string" || typeof ref.sha256 !== "string") return false;
|
|
41
|
+
const path = FILE_PATH.exec(ref.path);
|
|
42
|
+
const sha = FILE_SHA.exec(ref.sha256);
|
|
43
|
+
return Boolean(path && sha && path[1] === sha[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Writes `value` as canonical JSON and returns {path, sha256}. A file already
|
|
47
|
+
// at that address is re-read and must hold the same bytes; anything else
|
|
48
|
+
// throws, so the caller records the side as failed.
|
|
49
|
+
export function writeParityFile(targetRepo, value) {
|
|
50
|
+
const bytes = Buffer.from(canonicalJson(value), "utf8");
|
|
51
|
+
if (bytes.length > PARITY_FILE_MAX_BYTES) throw new Error("parity_file_too_large");
|
|
52
|
+
const hex = sha256Hex(bytes);
|
|
53
|
+
const root = realpathSync(targetRepo);
|
|
54
|
+
const path = `${PARITY_DIR}/${hex}.json`;
|
|
55
|
+
const directory = containedWritePath(root, PARITY_DIR);
|
|
56
|
+
containedWritePath(root, `${PARITY_DIR}/.gitignore`);
|
|
57
|
+
containedWritePath(root, path);
|
|
58
|
+
mkdirSync(directory, { recursive: true });
|
|
59
|
+
try { writeFileSync(resolve(directory, ".gitignore"), "*\n", { flag: "wx" }); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
60
|
+
// Exclusive create must not turn EEXIST into approval of a directory,
|
|
61
|
+
// device, link, or an ignore file that exposes the probes. Preserve operator
|
|
62
|
+
// bytes; refuse the write when they do not keep this directory ignored.
|
|
63
|
+
try {
|
|
64
|
+
const ignore = readRegularFileSync(resolve(directory, ".gitignore"), { root: directory, noFollow: true }).toString("utf8");
|
|
65
|
+
const patterns = ignore.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
66
|
+
if (!patterns.includes("*") || patterns.some((line) => line.startsWith("!"))) throw new Error("ignore_patterns_invalid");
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error("parity_ignore_invalid");
|
|
69
|
+
}
|
|
70
|
+
try { writeFileSync(resolve(root, path), bytes, { flag: "wx" }); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
71
|
+
const written = readProbeBytes({ sha256: `sha256:${hex}` }, root, bytes.length);
|
|
72
|
+
if (sha256Hex(written) !== hex) throw new Error("parity_write_verification_failed");
|
|
73
|
+
return { path, sha256: `sha256:${hex}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One referenced probe file, read beneath the parity directory and re-hashed:
|
|
77
|
+
// {status: "absent"} when it is not on this machine (a clone: the directory is
|
|
78
|
+
// ignored), {status: "invalid", detail} when the reference is malformed, the
|
|
79
|
+
// file's realpath leaves the directory, it is not a regular file, its bytes do
|
|
80
|
+
// not hash to the reference or do not parse, and {status: "ok", value}.
|
|
81
|
+
// Never throws.
|
|
82
|
+
export function readParityFile(ref, targetRepo) {
|
|
83
|
+
if (!parityRefShapeOk(ref)) return { status: "invalid", detail: "ref_shape_invalid" };
|
|
84
|
+
if (typeof targetRepo !== "string" || targetRepo === "") return { status: "invalid", detail: "target_unavailable" };
|
|
85
|
+
let bytes;
|
|
86
|
+
try {
|
|
87
|
+
bytes = readProbeBytes(ref, targetRepo);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return error?.code === "ENOENT" ? { status: "absent" } : { status: "invalid", detail: "file_unreadable" };
|
|
90
|
+
}
|
|
91
|
+
if (`sha256:${sha256Hex(bytes)}` !== ref.sha256) return { status: "invalid", detail: "hash_mismatch" };
|
|
92
|
+
try {
|
|
93
|
+
return { status: "ok", value: JSON.parse(bytes.toString("utf8")) };
|
|
94
|
+
} catch {
|
|
95
|
+
return { status: "invalid", detail: "parse_failed" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The parsed content of a referenced file whatever its hash, for naming the
|
|
100
|
+
// rows of a refused cell only (an accept then lapses instead of orphaning).
|
|
101
|
+
// Null when it cannot be read or parsed. Never authorizes evidence.
|
|
102
|
+
export function readParityFileForIdentity(ref, targetRepo) {
|
|
103
|
+
if (!parityRefShapeOk(ref) || typeof targetRepo !== "string" || targetRepo === "") return null;
|
|
104
|
+
try {
|
|
105
|
+
const bytes = readProbeBytes(ref, targetRepo);
|
|
106
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { inflateSync } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
export const VISUAL_MAX_DECODE_PIXELS = 17_280_000;
|
|
5
|
+
export const VISUAL_MAX_REFERENCE_BYTES = 67_108_864;
|
|
6
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
7
|
+
const depths = new Map([[0, [1, 2, 4, 8, 16]], [2, [8, 16]], [3, [1, 2, 4, 8]], [4, [8, 16]], [6, [8, 16]]]);
|
|
8
|
+
const channels = new Map([[0, 1], [2, 3], [3, 1], [4, 2], [6, 4]]);
|
|
9
|
+
export function boundedDimensions(width, height) {
|
|
10
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
|
|
11
|
+
|| width * height > VISUAL_MAX_DECODE_PIXELS) throw new RangeError("image_too_large");
|
|
12
|
+
}
|
|
13
|
+
export const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
|
|
15
|
+
const crcTable = Uint32Array.from({ length: 256 }, (_, byte) => {
|
|
16
|
+
let crc = byte;
|
|
17
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
18
|
+
return crc >>> 0;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function crc32(bytes) {
|
|
22
|
+
let crc = 0xffffffff;
|
|
23
|
+
for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ crcTable[(crc ^ bytes[i]) & 0xff];
|
|
24
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readPngStructure(bytes, { maxBytes = VISUAL_MAX_REFERENCE_BYTES } = {}) {
|
|
28
|
+
const fail = (reason) => ({ ok: false, level: "unverifiable", ihdr: null, reason });
|
|
29
|
+
if (!Number.isFinite(maxBytes) || maxBytes < 0) return fail("too_large");
|
|
30
|
+
maxBytes = Math.min(maxBytes, VISUAL_MAX_REFERENCE_BYTES);
|
|
31
|
+
if (!Buffer.isBuffer(bytes)) return fail("not_png");
|
|
32
|
+
if (bytes.length > maxBytes) return fail("too_large");
|
|
33
|
+
if (bytes.length < 8 || !Buffer.from(bytes.subarray(0, 8)).equals(signature)) return fail("not_png");
|
|
34
|
+
if (bytes.length < 33) return fail("truncated");
|
|
35
|
+
if (bytes.readUInt32BE(8) !== 13 || bytes.toString("ascii", 12, 16) !== "IHDR"
|
|
36
|
+
|| crc32(bytes.subarray(12, 29)) !== bytes.readUInt32BE(29)) return fail("ihdr_invalid");
|
|
37
|
+
const ihdr = {
|
|
38
|
+
width_px: bytes.readUInt32BE(16), height_px: bytes.readUInt32BE(20),
|
|
39
|
+
bit_depth: bytes[24], color_type: bytes[25], compression: bytes[26], filter: bytes[27], interlace: bytes[28],
|
|
40
|
+
};
|
|
41
|
+
if (!ihdr.width_px || !ihdr.height_px || ihdr.width_px > 0x7fffffff || ihdr.height_px > 0x7fffffff
|
|
42
|
+
|| !depths.get(ihdr.color_type)?.includes(ihdr.bit_depth) || ihdr.compression !== 0
|
|
43
|
+
|| ihdr.filter !== 0 || ![0, 1].includes(ihdr.interlace)) return fail("ihdr_invalid");
|
|
44
|
+
const idat = [];
|
|
45
|
+
let offset = 8;
|
|
46
|
+
while (offset < bytes.length) {
|
|
47
|
+
if (offset + 12 > bytes.length) return fail("truncated");
|
|
48
|
+
const length = bytes.readUInt32BE(offset);
|
|
49
|
+
const end = offset + 12 + length;
|
|
50
|
+
if (end > bytes.length) return fail("truncated");
|
|
51
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
52
|
+
if (crc32(bytes.subarray(offset + 4, end - 4)) !== bytes.readUInt32BE(end - 4)) return fail("chunk_crc_mismatch");
|
|
53
|
+
if (type === "IHDR" && offset !== 8) return fail("ihdr_invalid");
|
|
54
|
+
if (type === "IDAT") idat.push(bytes.subarray(offset + 8, end - 4));
|
|
55
|
+
if (type === "IEND") {
|
|
56
|
+
if (length || end !== bytes.length || !idat.length) return fail("truncated");
|
|
57
|
+
return { ok: true, level: "header_checked", ihdr, reason: null, idat };
|
|
58
|
+
}
|
|
59
|
+
offset = end;
|
|
60
|
+
}
|
|
61
|
+
return fail("truncated");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function rawImageBytes(ihdr) {
|
|
65
|
+
if (!ihdr || typeof ihdr !== "object") throw new RangeError("image_too_large");
|
|
66
|
+
const { width_px: width, height_px: height, bit_depth, color_type, interlace } = ihdr;
|
|
67
|
+
boundedDimensions(width, height);
|
|
68
|
+
if (!["width_px", "height_px", "bit_depth", "color_type", "interlace"].every((key) => Object.hasOwn(ihdr, key))
|
|
69
|
+
|| !depths.get(color_type)?.includes(bit_depth) || ![0, 1].includes(interlace)) throw new RangeError("ihdr_invalid");
|
|
70
|
+
const bits = bit_depth * channels.get(color_type);
|
|
71
|
+
const passBytes = (x, y, dx, dy) => {
|
|
72
|
+
const w = Math.max(0, Math.ceil((width - x) / dx));
|
|
73
|
+
const h = Math.max(0, Math.ceil((height - y) / dy));
|
|
74
|
+
return w && h ? h * (1 + Math.ceil(w * bits / 8)) : 0;
|
|
75
|
+
};
|
|
76
|
+
return interlace ? [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]]
|
|
77
|
+
.reduce((sum, pass) => sum + passBytes(...pass), 0) : passBytes(0, 0, 1, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function boundedInflate(bytes, ihdr) {
|
|
81
|
+
const expected = rawImageBytes(ihdr);
|
|
82
|
+
const structure = readPngStructure(bytes);
|
|
83
|
+
if (!structure.ok) throw new Error(structure.reason);
|
|
84
|
+
const raw = inflateSync(Buffer.concat(structure.idat), { maxOutputLength: expected });
|
|
85
|
+
if (raw.length !== expected) throw new Error("decode_failed");
|
|
86
|
+
return raw;
|
|
87
|
+
}
|