dsh-agent-team-ui-testing 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +327 -2
- package/annotation-capture.js +111 -0
- package/banner.png +0 -0
- package/banner_zh.png +0 -0
- package/browser-safety.js +293 -0
- package/capture-p0.js +910 -0
- package/client.js +11302 -0
- package/context-menu.js +134 -0
- package/cordis.patch.yml +3 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +146 -0
- package/docs/capability-tiers.md +80 -0
- package/docs/capture-p0.md +90 -0
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +143 -0
- package/docs/getting-started.md +136 -0
- package/docs/index.md +36 -0
- package/docs/multi-tab.md +162 -0
- package/docs/network-p0.md +117 -0
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +68 -0
- package/docs/t3-vision.md +84 -0
- package/docs/t4-dynamic-behavior.md +135 -0
- package/docs/tools.md +174 -0
- package/docs/verification.md +131 -0
- package/docs/viewport-control.md +94 -0
- package/dynamic-t4.js +5699 -0
- package/frame-push.js +508 -0
- package/frame-source.js +162 -0
- package/icon.png +0 -0
- package/index.js +23028 -0
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/network-p0.js +4264 -0
- package/package.json +19 -4
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/ui-testing-web/SKILL.md +520 -0
- package/skills/ui-testing-web/manifests/declared-capabilities.json +903 -0
- package/skills/ui-testing-web/references/contracts/capability.md +68 -0
- package/skills/ui-testing-web/references/contracts/evidence.md +41 -0
- package/skills/ui-testing-web/references/contracts/operation.md +44 -0
- package/skills/ui-testing-web/references/core/dynamic-visual.md +123 -0
- package/skills/ui-testing-web/references/core/fallback.md +83 -0
- package/skills/ui-testing-web/references/core/network-and-state.md +114 -0
- package/skills/ui-testing-web/references/core/page-identity.md +91 -0
- package/skills/ui-testing-web/references/core/report-template.md +144 -0
- package/skills/ui-testing-web/references/core/reporting.md +92 -0
- package/skills/ui-testing-web/references/core/safety.md +12 -0
- package/skills/ui-testing-web/references/core/selection.md +26 -0
- package/skills/ui-testing-web/references/core/tool-routing-and-limits.md +198 -0
- package/skills/ui-testing-web/references/core/verdict-examples.md +96 -0
- package/skills/ui-testing-web/references/core/verdict-gate.md +105 -0
- package/skills/ui-testing-web/references/core/verification.md +19 -0
- package/skills/ui-testing-web/references/core/workflow.md +16 -0
- package/skills/ui-testing-web/references/platforms/index.md +19 -0
- package/skills/ui-testing-web/references/platforms/web.md +17 -0
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/ui-testing-web/references/scenarios/functional.md +205 -0
- package/skills/ui-testing-web/references/scenarios/visual-motion.md +170 -0
- package/skills/ui-testing-web/references/tiers/t1-programmatic.md +25 -0
- package/skills/ui-testing-web/references/tiers/t2-structured.md +61 -0
- package/skills/ui-testing-web/references/tiers/t3-static-visual.md +45 -0
- package/skills/ui-testing-web/references/tiers/t4-dynamic-visual.md +58 -0
- package/skills/ui-testing-web/templates/capability-note.md +23 -0
- package/skills/ui-testing-web/templates/platform.md +30 -0
- package/skills/ui-testing-web/templates/regression-case.md +15 -0
- package/skills/ui-testing-web/templates/scenario.md +13 -0
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/vision-baseline.js +1254 -0
- package/vision-t3.js +2657 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vision-baseline.js — Web T3 受管基线存储(docs/t3-vision.md、§3.3、§8、§9 C33–C39)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界:本模块只负责“可信持久根 + 不可变 revision + head CAS + tombstone + 只读 list/get”,
|
|
5
|
+
* 不做 PNG 解码/比较/产品 store,也不解析 source(index.js 负责把 source 解析成 product 后
|
|
6
|
+
* 把同份已校验字节交给这里)。所有文件操作都是有限的同步调用;错误一律 BrowserError。
|
|
7
|
+
*
|
|
8
|
+
* 磁盘布局(scopeDir = <root>/output/vision-baselines/<sha256(scopeId).slice(0,32)>):
|
|
9
|
+
* <scopeDir>/<name>/images/<revision>-<actionId>.png (0600,no-clobber,永不原地覆盖)
|
|
10
|
+
* <scopeDir>/<name>/heads/0000000001.json (0600,规范 JSON,十位零填充)
|
|
11
|
+
* <scopeDir>/<name>/heads/0000000002.json
|
|
12
|
+
* ...
|
|
13
|
+
* 提交点 = 把 head 临时文件 `link` 成 `heads/<n>.json` 成功的那一刻;此前失败只清理本次临时文件
|
|
14
|
+
* 与本次未引用 PNG,此后失败绝不删除已提交 HEAD/PNG(错误 details 带 committed:true)。
|
|
15
|
+
*
|
|
16
|
+
* 规范 head JSON:递归 key 字典序、无空白、末尾 LF;包含 BaselineMeta 全部字段,但不写 headToken
|
|
17
|
+
* (headToken 在返回时按精确文件字节 sha256 计算)。parentHeadToken 是上一 head 文件字节的 sha256。
|
|
18
|
+
*
|
|
19
|
+
* image.relativePath 相对 **scopeDir**(即 `<name>/images/<revision>-<actionId>.png`),
|
|
20
|
+
* 便于审计时从受管根定位文件。
|
|
21
|
+
*
|
|
22
|
+
* 方法全部是同步返回、同步抛错(`await` 兼容);fsOps 可注入(键名同 node:fs 同步 API),
|
|
23
|
+
* 用于故障注入与确定性单测。默认 node:fs。
|
|
24
|
+
*/
|
|
25
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
26
|
+
import * as nodeFs from 'node:fs';
|
|
27
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { BrowserError } from './browser-safety.js';
|
|
30
|
+
import { PNG_SIGNATURE, asBuffer, inspectPng } from './capture-p0.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 基线存储的公共限额(index.js 的 schema/capabilities 可复用;单测直接断言)。
|
|
34
|
+
* maxRevision 与 §2.1 schema 的 revision/expectedRevision 上限一致。
|
|
35
|
+
*/
|
|
36
|
+
export const BASELINE_LIMITS = Object.freeze({
|
|
37
|
+
maxRevision: 9_999_999_999,
|
|
38
|
+
maxListEntries: 1_000,
|
|
39
|
+
maxPngBytes: 32 * 1024 * 1024,
|
|
40
|
+
maxPngPixels: 16_000_000,
|
|
41
|
+
headNameWidth: 10,
|
|
42
|
+
maxHeadBytes: 1024 * 1024,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
46
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
47
|
+
const HEAD_FILE_PATTERN = /^(\d{10})\.json$/;
|
|
48
|
+
const IMAGE_FILE_PATTERN = /^(\d{1,10})-([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})\.png$/;
|
|
49
|
+
const TEMP_PREFIX = '.tmp-';
|
|
50
|
+
const OUTPUT_SEGMENT = 'output';
|
|
51
|
+
const BASELINE_SEGMENT = 'vision-baselines';
|
|
52
|
+
const HEADS_SEGMENT = 'heads';
|
|
53
|
+
const IMAGES_SEGMENT = 'images';
|
|
54
|
+
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
|
|
55
|
+
const MAX_PRODUCT_ID_LENGTH = 256;
|
|
56
|
+
|
|
57
|
+
/** BaselineMeta 的 head 文件字段(排序后),headToken 不写入自身。 */
|
|
58
|
+
const HEAD_FIELDS = Object.freeze([
|
|
59
|
+
'caseId', 'createdAt', 'image', 'mapping', 'masks', 'name', 'options',
|
|
60
|
+
'parentHeadToken', 'parentRevision', 'provenanceVersion', 'revision',
|
|
61
|
+
'source', 'thresholds', 'tombstone',
|
|
62
|
+
]);
|
|
63
|
+
const OPTION_KEYS = Object.freeze(['includeAA', 'minRegionPixels', 'threshold']);
|
|
64
|
+
const THRESHOLD_KEYS = Object.freeze(['failRatio', 'warnRatio']);
|
|
65
|
+
const MASK_KEYS = 'id,region';
|
|
66
|
+
const RECT_KEYS = 'height,width,x,y';
|
|
67
|
+
const SOURCE_KEYS = 'capturedAt,documentEpoch,pageGeneration,productId,runtimeId,sha256';
|
|
68
|
+
const IMAGE_KEYS = 'bytes,height,relativePath,sha256,width';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 默认算法默认值:与 docs/t3-vision.md 固定值一致。此处不 import vision-t3.js,
|
|
72
|
+
* 避免基线存储依赖算法切片(默认值本身是契约常量,跨切片重复比隐式耦合安全)。
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_DIFF_OPTIONS = Object.freeze({ threshold: 0.1, includeAA: false, minRegionPixels: 1 });
|
|
75
|
+
const DEFAULT_THRESHOLDS = Object.freeze({ warnRatio: 0.001, failRatio: 0.01 });
|
|
76
|
+
|
|
77
|
+
/** 目录 fsync 在这些 errno 下视为“平台不支持目录 fsync”,与 P0 publishCapture 一样按尽力而为处理。 */
|
|
78
|
+
const TOLERATED_DIR_FSYNC = new Set(['EINVAL', 'ENOTSUP', 'EISDIR', 'EBADF', 'ENOSYS', 'ENOTTY',
|
|
79
|
+
// win32 has no directory fsync: Node surfaces EPERM/EACCES instead of EINVAL, so without this a
|
|
80
|
+
// successfully published baseline PNG is unlinked again and reported as a write failure.
|
|
81
|
+
...(process.platform === 'win32' ? ['EPERM', 'EACCES'] : [])]);
|
|
82
|
+
|
|
83
|
+
const DEFAULT_FS_OPS = Object.freeze({
|
|
84
|
+
realpathSync: nodeFs.realpathSync,
|
|
85
|
+
statSync: nodeFs.statSync,
|
|
86
|
+
lstatSync: nodeFs.lstatSync,
|
|
87
|
+
mkdirSync: nodeFs.mkdirSync,
|
|
88
|
+
chmodSync: nodeFs.chmodSync,
|
|
89
|
+
readdirSync: nodeFs.readdirSync,
|
|
90
|
+
readFileSync: nodeFs.readFileSync,
|
|
91
|
+
openSync: nodeFs.openSync,
|
|
92
|
+
writeSync: nodeFs.writeSync,
|
|
93
|
+
fsyncSync: nodeFs.fsyncSync,
|
|
94
|
+
closeSync: nodeFs.closeSync,
|
|
95
|
+
linkSync: nodeFs.linkSync,
|
|
96
|
+
unlinkSync: nodeFs.unlinkSync,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/* ------------------------------------------------------------------------------------------------
|
|
100
|
+
* 纯工具
|
|
101
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
102
|
+
|
|
103
|
+
function sha256Hex(bytes) {
|
|
104
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPlainObject(value) {
|
|
108
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && !Buffer.isBuffer(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isInside(root, target) {
|
|
112
|
+
const rel = relative(root, target);
|
|
113
|
+
if (rel === '') return true;
|
|
114
|
+
if (isAbsolute(rel)) return false;
|
|
115
|
+
return rel !== '..' && !rel.startsWith('..' + sep);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 递归按 key 字典序排序(数组保持顺序),用于规范 JSON。 */
|
|
119
|
+
function canonicalize(value) {
|
|
120
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
121
|
+
if (value !== null && typeof value === 'object') {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const key of Object.keys(value).sort()) {
|
|
124
|
+
if (value[key] === undefined) continue;
|
|
125
|
+
out[key] = canonicalize(value[key]);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 规范 JSON:UTF8、key 递归字典序、无空白、末尾 LF。 */
|
|
133
|
+
function canonicalJson(value) {
|
|
134
|
+
return JSON.stringify(canonicalize(value)) + '\n';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function cloneJson(value) {
|
|
138
|
+
return JSON.parse(JSON.stringify(value));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function headFileName(revision) {
|
|
142
|
+
return String(revision).padStart(BASELINE_LIMITS.headNameWidth, '0') + '.json';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeReasonFor(error) {
|
|
146
|
+
const code = error && error.code ? String(error.code) : '';
|
|
147
|
+
if (code === 'EACCES' || code === 'EPERM') return 'permission';
|
|
148
|
+
if (code === 'EROFS') return 'read-only';
|
|
149
|
+
return 'io';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ------------------------------------------------------------------------------------------------
|
|
153
|
+
* 只做形状校验的读取器(返回 null 表示非法;写路径抛 INVALID_ARGUMENT,读路径报 READ_FAILED/corrupt)
|
|
154
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
155
|
+
|
|
156
|
+
function readRatio(value) {
|
|
157
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readInteger(value, min, max) {
|
|
161
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readNameValue(value) {
|
|
165
|
+
return typeof value === 'string' && NAME_PATTERN.test(value) ? value : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readPixelRect(value) {
|
|
169
|
+
if (!isPlainObject(value)) return null;
|
|
170
|
+
if (Object.keys(value).sort().join(',') !== RECT_KEYS) return null;
|
|
171
|
+
const x = readInteger(value.x, 0, MAX_SAFE);
|
|
172
|
+
const y = readInteger(value.y, 0, MAX_SAFE);
|
|
173
|
+
const width = readInteger(value.width, 1, MAX_SAFE);
|
|
174
|
+
const height = readInteger(value.height, 1, MAX_SAFE);
|
|
175
|
+
if (x === null || y === null || width === null || height === null) return null;
|
|
176
|
+
return { x, y, width, height };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readMaskList(value) {
|
|
180
|
+
if (!Array.isArray(value) || value.length > 64) return null;
|
|
181
|
+
const ids = new Set();
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const entry of value) {
|
|
184
|
+
if (!isPlainObject(entry)) return null;
|
|
185
|
+
if (Object.keys(entry).sort().join(',') !== MASK_KEYS) return null;
|
|
186
|
+
const id = readNameValue(entry.id);
|
|
187
|
+
const region = readPixelRect(entry.region);
|
|
188
|
+
if (!id || !region || ids.has(id)) return null;
|
|
189
|
+
ids.add(id);
|
|
190
|
+
out.push({ id, region });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 局部 options 覆盖(字段可缺省);返回 partial 或 null。 */
|
|
196
|
+
function readOptionsPatch(value) {
|
|
197
|
+
if (!isPlainObject(value)) return null;
|
|
198
|
+
for (const key of Object.keys(value)) if (!OPTION_KEYS.includes(key)) return null;
|
|
199
|
+
const out = {};
|
|
200
|
+
if (value.threshold !== undefined) {
|
|
201
|
+
const threshold = readRatio(value.threshold);
|
|
202
|
+
if (threshold === null) return null;
|
|
203
|
+
out.threshold = threshold;
|
|
204
|
+
}
|
|
205
|
+
if (value.includeAA !== undefined) {
|
|
206
|
+
if (typeof value.includeAA !== 'boolean') return null;
|
|
207
|
+
out.includeAA = value.includeAA;
|
|
208
|
+
}
|
|
209
|
+
if (value.minRegionPixels !== undefined) {
|
|
210
|
+
const minRegionPixels = readInteger(value.minRegionPixels, 1, 16_000_000);
|
|
211
|
+
if (minRegionPixels === null) return null;
|
|
212
|
+
out.minRegionPixels = minRegionPixels;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readOptionsFull(value) {
|
|
218
|
+
if (!isPlainObject(value)) return null;
|
|
219
|
+
if (Object.keys(value).sort().join(',') !== OPTION_KEYS.join(',')) return null;
|
|
220
|
+
const patch = readOptionsPatch(value);
|
|
221
|
+
if (!patch) return null;
|
|
222
|
+
if (patch.threshold === undefined || patch.includeAA === undefined || patch.minRegionPixels === undefined) return null;
|
|
223
|
+
return patch;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readThresholdsPatch(value) {
|
|
227
|
+
if (!isPlainObject(value)) return null;
|
|
228
|
+
for (const key of Object.keys(value)) if (!THRESHOLD_KEYS.includes(key)) return null;
|
|
229
|
+
const out = {};
|
|
230
|
+
if (value.warnRatio !== undefined) {
|
|
231
|
+
const warnRatio = readRatio(value.warnRatio);
|
|
232
|
+
if (warnRatio === null) return null;
|
|
233
|
+
out.warnRatio = warnRatio;
|
|
234
|
+
}
|
|
235
|
+
if (value.failRatio !== undefined) {
|
|
236
|
+
const failRatio = readRatio(value.failRatio);
|
|
237
|
+
if (failRatio === null) return null;
|
|
238
|
+
out.failRatio = failRatio;
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readThresholdsFull(value) {
|
|
244
|
+
if (!isPlainObject(value)) return null;
|
|
245
|
+
if (Object.keys(value).sort().join(',') !== THRESHOLD_KEYS.join(',')) return null;
|
|
246
|
+
const patch = readThresholdsPatch(value);
|
|
247
|
+
if (!patch) return null;
|
|
248
|
+
if (patch.warnRatio === undefined || patch.failRatio === undefined) return null;
|
|
249
|
+
if (patch.warnRatio > patch.failRatio) return null;
|
|
250
|
+
return patch;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readNullableInteger(value) {
|
|
254
|
+
if (value === null) return { ok: true, value: null };
|
|
255
|
+
const parsed = readInteger(value, 0, MAX_SAFE);
|
|
256
|
+
return parsed === null ? { ok: false, value: null } : { ok: true, value: parsed };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readNullableString(value) {
|
|
260
|
+
if (value === null) return { ok: true, value: null };
|
|
261
|
+
return typeof value === 'string' ? { ok: true, value } : { ok: false, value: null };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 磁盘上的 source 摘要:必须恰好是写路径规范化的 6 个字段。 */
|
|
265
|
+
function readSourceSummary(value) {
|
|
266
|
+
if (!isPlainObject(value)) return null;
|
|
267
|
+
if (Object.keys(value).sort().join(',') !== SOURCE_KEYS) return null;
|
|
268
|
+
const productId = typeof value.productId === 'string' && value.productId.length > 0
|
|
269
|
+
&& value.productId.length <= MAX_PRODUCT_ID_LENGTH ? value.productId : null;
|
|
270
|
+
if (!productId) return null;
|
|
271
|
+
if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) return null;
|
|
272
|
+
const capturedAt = readNullableInteger(value.capturedAt);
|
|
273
|
+
const pageGeneration = readNullableInteger(value.pageGeneration);
|
|
274
|
+
const documentEpoch = readNullableInteger(value.documentEpoch);
|
|
275
|
+
const runtimeId = readNullableString(value.runtimeId);
|
|
276
|
+
if (!capturedAt.ok || !pageGeneration.ok || !documentEpoch.ok || !runtimeId.ok) return null;
|
|
277
|
+
return {
|
|
278
|
+
productId,
|
|
279
|
+
sha256: value.sha256,
|
|
280
|
+
capturedAt: capturedAt.value,
|
|
281
|
+
runtimeId: runtimeId.value,
|
|
282
|
+
pageGeneration: pageGeneration.value,
|
|
283
|
+
documentEpoch: documentEpoch.value,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** image 元数据(不含 relativePath 的锚点校验);返回规范化对象或 null。 */
|
|
288
|
+
function readImageMeta(value) {
|
|
289
|
+
if (!isPlainObject(value)) return null;
|
|
290
|
+
if (Object.keys(value).sort().join(',') !== IMAGE_KEYS) return null;
|
|
291
|
+
const bytes = readInteger(value.bytes, 1, MAX_SAFE);
|
|
292
|
+
const width = readInteger(value.width, 1, MAX_SAFE);
|
|
293
|
+
const height = readInteger(value.height, 1, MAX_SAFE);
|
|
294
|
+
if (bytes === null || width === null || height === null) return null;
|
|
295
|
+
if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) return null;
|
|
296
|
+
if (typeof value.relativePath !== 'string' || value.relativePath.length === 0) return null;
|
|
297
|
+
return { relativePath: value.relativePath, sha256: value.sha256, width, height, bytes };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 校验 image.relativePath 必须是 `<name>/images/<revision>-<actionId>.png`(相对 scopeDir),
|
|
302
|
+
* 返回安全的文件名;任何 `..`、绝对路径、分隔符、revision 不符都返回 null。
|
|
303
|
+
*/
|
|
304
|
+
function imageFileName(relativePath, name, revision) {
|
|
305
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) return null;
|
|
306
|
+
if (relativePath.includes('\0') || relativePath.includes('\\')) return null;
|
|
307
|
+
if (relativePath.startsWith('/') || /^[A-Za-z]:/.test(relativePath)) return null;
|
|
308
|
+
const prefix = `${name}/${IMAGES_SEGMENT}/`;
|
|
309
|
+
if (!relativePath.startsWith(prefix)) return null;
|
|
310
|
+
const fileName = relativePath.slice(prefix.length);
|
|
311
|
+
if (fileName.includes('/') || fileName.includes('\\')) return null;
|
|
312
|
+
const match = IMAGE_FILE_PATTERN.exec(fileName);
|
|
313
|
+
if (!match) return null;
|
|
314
|
+
if (Number(match[1]) !== revision) return null;
|
|
315
|
+
return fileName;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ------------------------------------------------------------------------------------------------
|
|
319
|
+
* 作用域哈希 / store 构造
|
|
320
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* scopeHash = sha256(scopeId UTF8).hex.slice(0,32)。scopeId 只来自可信解析链;
|
|
324
|
+
* 缺失/空字符串按“scope 无工作区身份”处理(§3.3 ROOT_UNAVAILABLE)。
|
|
325
|
+
*/
|
|
326
|
+
export function baselineScopeHash(scopeId) {
|
|
327
|
+
if (typeof scopeId !== 'string' || scopeId.trim() === '') {
|
|
328
|
+
throw new BrowserError('VISION_BASELINE_ROOT_UNAVAILABLE', '基线持久根不可用', { reason: 'scope_invalid' });
|
|
329
|
+
}
|
|
330
|
+
return createHash('sha256').update(scopeId, 'utf8').digest('hex').slice(0, 32);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function canonicalRootOf(fs, root) {
|
|
334
|
+
const unavailable = (reason) => new BrowserError(
|
|
335
|
+
'VISION_BASELINE_ROOT_UNAVAILABLE', '基线持久根不可用', { reason },
|
|
336
|
+
);
|
|
337
|
+
if (typeof root !== 'string' || root.trim() === '') throw unavailable('root_missing');
|
|
338
|
+
if (!isAbsolute(root)) throw unavailable('root_not_absolute');
|
|
339
|
+
let info;
|
|
340
|
+
try {
|
|
341
|
+
info = fs.statSync(root);
|
|
342
|
+
} catch {
|
|
343
|
+
throw unavailable('root_unavailable');
|
|
344
|
+
}
|
|
345
|
+
if (!info.isDirectory()) throw unavailable('root_not_directory');
|
|
346
|
+
try {
|
|
347
|
+
return fs.realpathSync(root);
|
|
348
|
+
} catch {
|
|
349
|
+
throw unavailable('root_unavailable');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function normalizeClock(now) {
|
|
354
|
+
if (now === undefined || now === null) return () => Date.now();
|
|
355
|
+
if (typeof now === 'number') {
|
|
356
|
+
if (!Number.isInteger(now) || now < 0) {
|
|
357
|
+
throw new BrowserError('INVALID_ARGUMENT', 'now 必须是有限非负整数或函数', { field: 'now' });
|
|
358
|
+
}
|
|
359
|
+
return () => now;
|
|
360
|
+
}
|
|
361
|
+
if (typeof now !== 'function') {
|
|
362
|
+
throw new BrowserError('INVALID_ARGUMENT', 'now 必须是有限非负整数或函数', { field: 'now' });
|
|
363
|
+
}
|
|
364
|
+
return () => {
|
|
365
|
+
const value = now();
|
|
366
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
367
|
+
throw new BrowserError('INVALID_ARGUMENT', 'now() 必须返回有限非负整数', { field: 'now' });
|
|
368
|
+
}
|
|
369
|
+
return value;
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 创建受管基线 store。同步 API:list/get/create/update/delete 直接返回值或抛 BrowserError。
|
|
375
|
+
*
|
|
376
|
+
* @param {{root?:string, scopeId?:string, now?:number|(()=>number), fsOps?:object}} [options]
|
|
377
|
+
* @returns {{scopeHash:string, list:Function, get:Function, create:Function, update:Function, delete:Function}}
|
|
378
|
+
*/
|
|
379
|
+
export function createBaselineStore({ root, scopeId, now, fsOps } = {}) {
|
|
380
|
+
const fs = { ...DEFAULT_FS_OPS };
|
|
381
|
+
if (fsOps !== undefined && fsOps !== null) {
|
|
382
|
+
if (typeof fsOps !== 'object') {
|
|
383
|
+
throw new BrowserError('INVALID_ARGUMENT', 'fsOps 必须是对象', { field: 'fsOps' });
|
|
384
|
+
}
|
|
385
|
+
for (const key of Object.keys(fsOps)) {
|
|
386
|
+
if (typeof fsOps[key] === 'function') fs[key] = fsOps[key];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const clock = normalizeClock(now);
|
|
390
|
+
const canonicalRoot = canonicalRootOf(fs, root);
|
|
391
|
+
const scopeHash = baselineScopeHash(scopeId);
|
|
392
|
+
const storeDir = join(canonicalRoot, OUTPUT_SEGMENT, BASELINE_SEGMENT, scopeHash);
|
|
393
|
+
|
|
394
|
+
/* ----------------------------------------------------------------------------------------------
|
|
395
|
+
* 错误构造(message/details 一律不含绝对路径)
|
|
396
|
+
* -------------------------------------------------------------------------------------------- */
|
|
397
|
+
|
|
398
|
+
function invalidArgument(message, field) {
|
|
399
|
+
throw new BrowserError('INVALID_ARGUMENT', message, { field });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function baselineNameInvalid() {
|
|
403
|
+
throw new BrowserError('VISION_BASELINE_NAME_INVALID', '基线名称不合法', { field: 'name' });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function notFound(name, revision) {
|
|
407
|
+
const details = { name };
|
|
408
|
+
if (revision !== undefined) details.revision = revision;
|
|
409
|
+
throw new BrowserError('VISION_BASELINE_NOT_FOUND', '基线不存在', details);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function baselineExists(name) {
|
|
413
|
+
throw new BrowserError('VISION_BASELINE_EXISTS', '基线名称已被使用', { name });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function baselineConflict(detail, extra) {
|
|
417
|
+
throw new BrowserError('VISION_BASELINE_CONFLICT', '基线版本冲突', { detail, ...extra });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function readFailure(reason, detail, extra) {
|
|
421
|
+
throw new BrowserError('VISION_BASELINE_READ_FAILED', '基线读取失败', { reason, detail, ...extra });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function writeFailure(reason, detail, extra) {
|
|
425
|
+
throw new BrowserError('VISION_BASELINE_WRITE_FAILED', '基线写入失败', { reason, detail, ...extra });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function writeFailureFrom(error, detail) {
|
|
429
|
+
writeFailure(writeReasonFor(error), detail, { fsCode: error && error.code ? String(error.code) : null });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function limitExceeded(limit, actual, max) {
|
|
433
|
+
throw new BrowserError('VISION_LIMIT_EXCEEDED', '基线资源上限超限', { limit, actual, max });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* ----------------------------------------------------------------------------------------------
|
|
437
|
+
* 文件系统守卫
|
|
438
|
+
* -------------------------------------------------------------------------------------------- */
|
|
439
|
+
|
|
440
|
+
function lstatOrNull(path, mode) {
|
|
441
|
+
try {
|
|
442
|
+
return fs.lstatSync(path);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error && error.code === 'ENOENT') return null;
|
|
445
|
+
const reason = mode === 'write' ? writeReasonFor(error) : 'io';
|
|
446
|
+
const code = mode === 'write' ? 'VISION_BASELINE_WRITE_FAILED' : 'VISION_BASELINE_READ_FAILED';
|
|
447
|
+
throw new BrowserError(code, mode === 'write' ? '基线写入失败' : '基线读取失败', {
|
|
448
|
+
reason, detail: 'lstat_failed', fsCode: error && error.code ? String(error.code) : null,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function realpathChecked(path, mode) {
|
|
454
|
+
let real;
|
|
455
|
+
try {
|
|
456
|
+
real = fs.realpathSync(path);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const reason = mode === 'write' ? writeReasonFor(error) : 'io';
|
|
459
|
+
const code = mode === 'write' ? 'VISION_BASELINE_WRITE_FAILED' : 'VISION_BASELINE_READ_FAILED';
|
|
460
|
+
throw new BrowserError(code, mode === 'write' ? '基线写入失败' : '基线读取失败', {
|
|
461
|
+
reason, detail: 'realpath_failed', fsCode: error && error.code ? String(error.code) : null,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (!isInside(canonicalRoot, real)) {
|
|
465
|
+
if (mode === 'write') writeFailure('io', 'path_escape');
|
|
466
|
+
readFailure('io', 'path_escape');
|
|
467
|
+
}
|
|
468
|
+
return real;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function assertDirInfo(info, path, label, mode) {
|
|
472
|
+
if (info.isSymbolicLink()) {
|
|
473
|
+
if (mode === 'write') writeFailure('io', `${label}_symlink`);
|
|
474
|
+
readFailure('io', `${label}_symlink`);
|
|
475
|
+
}
|
|
476
|
+
if (!info.isDirectory()) {
|
|
477
|
+
if (mode === 'write') writeFailure('io', `${label}_not_directory`);
|
|
478
|
+
readFailure('io', `${label}_not_directory`);
|
|
479
|
+
}
|
|
480
|
+
realpathChecked(path, mode);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function ensureDir(path) {
|
|
484
|
+
const info = lstatOrNull(path, 'write');
|
|
485
|
+
if (info) {
|
|
486
|
+
assertDirInfo(info, path, 'dir', 'write');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
fs.mkdirSync(path, { mode: 0o700 });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error && error.code === 'EEXIST') {
|
|
493
|
+
const again = lstatOrNull(path, 'write');
|
|
494
|
+
if (again) {
|
|
495
|
+
assertDirInfo(again, path, 'dir', 'write');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
writeFailureFrom(error, 'mkdir_failed');
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
fs.chmodSync(path, 0o700);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
writeFailureFrom(error, 'chmod_failed');
|
|
505
|
+
}
|
|
506
|
+
realpathChecked(path, 'write');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 解析受管链 root/output/vision-baselines/<scopeHash>。
|
|
511
|
+
* @param {{write?:boolean, create?:boolean}} [options]
|
|
512
|
+
* @returns {{state:'absent'}|{state:'ready', storeDir:string}}
|
|
513
|
+
*/
|
|
514
|
+
function resolveStore({ write = false, create = false } = {}) {
|
|
515
|
+
const mode = write ? 'write' : 'read';
|
|
516
|
+
let current = canonicalRoot;
|
|
517
|
+
for (const segment of [OUTPUT_SEGMENT, BASELINE_SEGMENT, scopeHash]) {
|
|
518
|
+
current = join(current, segment);
|
|
519
|
+
const info = lstatOrNull(current, mode);
|
|
520
|
+
if (!info) {
|
|
521
|
+
if (!create) return { state: 'absent' };
|
|
522
|
+
ensureDir(current);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
assertDirInfo(info, current, segment === scopeHash ? 'scope' : segment, mode);
|
|
526
|
+
}
|
|
527
|
+
return { state: 'ready', storeDir: current };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function ensureNameDirs(storeDirs, name) {
|
|
531
|
+
const nameDir = join(storeDirs.storeDir, name);
|
|
532
|
+
ensureDir(nameDir);
|
|
533
|
+
const headsDir = join(nameDir, HEADS_SEGMENT);
|
|
534
|
+
ensureDir(headsDir);
|
|
535
|
+
const imagesDir = join(nameDir, IMAGES_SEGMENT);
|
|
536
|
+
ensureDir(imagesDir);
|
|
537
|
+
return { nameDir, headsDir, imagesDir };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** update 已在读链时拿到 name 目录;只补 images/heads(例如崩溃遗留)并逐级守卫。 */
|
|
541
|
+
function ensureExistingNameDirs(nameDirs) {
|
|
542
|
+
ensureDir(nameDirs.nameDir);
|
|
543
|
+
ensureDir(nameDirs.headsDir);
|
|
544
|
+
ensureDir(nameDirs.imagesDir);
|
|
545
|
+
return nameDirs;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function resolveNameDirs(storeDirs, name, mode) {
|
|
549
|
+
const nameDir = join(storeDirs.storeDir, name);
|
|
550
|
+
const nameInfo = lstatOrNull(nameDir, mode);
|
|
551
|
+
if (!nameInfo) return { state: 'absent' };
|
|
552
|
+
assertDirInfo(nameInfo, nameDir, 'name', mode);
|
|
553
|
+
const headsDir = join(nameDir, HEADS_SEGMENT);
|
|
554
|
+
const headsInfo = lstatOrNull(headsDir, mode);
|
|
555
|
+
if (!headsInfo) return { state: 'uncommitted' };
|
|
556
|
+
assertDirInfo(headsInfo, headsDir, 'heads', mode);
|
|
557
|
+
return { state: 'ready', nameDir, headsDir, imagesDir: join(nameDir, IMAGES_SEGMENT) };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/* ----------------------------------------------------------------------------------------------
|
|
561
|
+
* 输入规范化(写路径)
|
|
562
|
+
* -------------------------------------------------------------------------------------------- */
|
|
563
|
+
|
|
564
|
+
function assertName(name) {
|
|
565
|
+
const parsed = readNameValue(name);
|
|
566
|
+
if (!parsed) baselineNameInvalid();
|
|
567
|
+
return parsed;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function assertCaseId(value) {
|
|
571
|
+
const parsed = readNameValue(value);
|
|
572
|
+
if (!parsed) invalidArgument('caseId 不合法', 'caseId');
|
|
573
|
+
return parsed;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function assertActionId(actionId) {
|
|
577
|
+
const parsed = readNameValue(actionId);
|
|
578
|
+
if (!parsed) invalidArgument('actionId 不合法', 'actionId');
|
|
579
|
+
return parsed;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function assertRevision(value, field) {
|
|
583
|
+
const parsed = readInteger(value, 1, BASELINE_LIMITS.maxRevision);
|
|
584
|
+
if (parsed === null) invalidArgument(`${field} 必须是 1..9999999999 的整数`, field);
|
|
585
|
+
return parsed;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeSource(source) {
|
|
589
|
+
if (!isPlainObject(source)) invalidArgument('source 必须是对象', 'source');
|
|
590
|
+
const productId = typeof source.productId === 'string' && source.productId.length > 0
|
|
591
|
+
&& source.productId.length <= MAX_PRODUCT_ID_LENGTH ? source.productId : null;
|
|
592
|
+
if (!productId) invalidArgument('source.productId 缺失或非法', 'source.productId');
|
|
593
|
+
if (typeof source.sha256 !== 'string' || !SHA256_PATTERN.test(source.sha256)) {
|
|
594
|
+
invalidArgument('source.sha256 缺失或非法', 'source.sha256');
|
|
595
|
+
}
|
|
596
|
+
const capturedAt = source.capturedAt === undefined || source.capturedAt === null
|
|
597
|
+
? { ok: true, value: null } : readNullableInteger(source.capturedAt);
|
|
598
|
+
const pageGeneration = source.pageGeneration === undefined || source.pageGeneration === null
|
|
599
|
+
? { ok: true, value: null } : readNullableInteger(source.pageGeneration);
|
|
600
|
+
const documentEpoch = source.documentEpoch === undefined || source.documentEpoch === null
|
|
601
|
+
? { ok: true, value: null } : readNullableInteger(source.documentEpoch);
|
|
602
|
+
const runtimeId = source.runtimeId === undefined || source.runtimeId === null
|
|
603
|
+
? { ok: true, value: null } : readNullableString(source.runtimeId);
|
|
604
|
+
if (!capturedAt.ok) invalidArgument('source.capturedAt 非法', 'source.capturedAt');
|
|
605
|
+
if (!pageGeneration.ok) invalidArgument('source.pageGeneration 非法', 'source.pageGeneration');
|
|
606
|
+
if (!documentEpoch.ok) invalidArgument('source.documentEpoch 非法', 'source.documentEpoch');
|
|
607
|
+
if (!runtimeId.ok) invalidArgument('source.runtimeId 非法', 'source.runtimeId');
|
|
608
|
+
return {
|
|
609
|
+
productId,
|
|
610
|
+
sha256: source.sha256,
|
|
611
|
+
capturedAt: capturedAt.value,
|
|
612
|
+
runtimeId: runtimeId.value,
|
|
613
|
+
pageGeneration: pageGeneration.value,
|
|
614
|
+
documentEpoch: documentEpoch.value,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function normalizeMapping(value) {
|
|
619
|
+
if (value === undefined) return { provided: false, value: null };
|
|
620
|
+
if (value === null) return { provided: true, value: null };
|
|
621
|
+
if (!isPlainObject(value)) invalidArgument('mapping 必须是对象或 null', 'mapping');
|
|
622
|
+
try {
|
|
623
|
+
return { provided: true, value: canonicalize(JSON.parse(JSON.stringify(value))) };
|
|
624
|
+
} catch {
|
|
625
|
+
invalidArgument('mapping 必须是 JSON 可序列化对象', 'mapping');
|
|
626
|
+
}
|
|
627
|
+
return { provided: true, value: null };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function resolveOptions(value, base) {
|
|
631
|
+
const resolved = { ...(base === null || base === undefined ? DEFAULT_DIFF_OPTIONS : base) };
|
|
632
|
+
if (value === undefined) return resolved;
|
|
633
|
+
const patch = readOptionsPatch(value);
|
|
634
|
+
if (!patch) invalidArgument('options 非法', 'options');
|
|
635
|
+
return { ...resolved, ...patch };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function resolveThresholds(value, base) {
|
|
639
|
+
const resolved = { ...(base === null || base === undefined ? DEFAULT_THRESHOLDS : base) };
|
|
640
|
+
if (value !== undefined) {
|
|
641
|
+
const patch = readThresholdsPatch(value);
|
|
642
|
+
if (!patch) invalidArgument('thresholds 非法', 'thresholds');
|
|
643
|
+
Object.assign(resolved, patch);
|
|
644
|
+
}
|
|
645
|
+
if (resolved.warnRatio > resolved.failRatio) {
|
|
646
|
+
invalidArgument('thresholds.warnRatio 不能大于 failRatio', 'thresholds');
|
|
647
|
+
}
|
|
648
|
+
return resolved;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function resolveMasks(value, base) {
|
|
652
|
+
if (value === undefined) return cloneJson(base === null || base === undefined ? [] : base);
|
|
653
|
+
const masks = readMaskList(value);
|
|
654
|
+
if (!masks) invalidArgument('masks 非法', 'masks');
|
|
655
|
+
return masks;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 只读 PNG 签名 + IHDR 声明的宽高(不校验 CRC/块结构;结构交给 inspectPng)。 */
|
|
659
|
+
function pngDeclaredSize(buffer) {
|
|
660
|
+
if (buffer.length < 24) return null;
|
|
661
|
+
if (!buffer.subarray(0, 8).equals(Buffer.from(PNG_SIGNATURE))) return null;
|
|
662
|
+
const width = buffer.readUInt32BE(16);
|
|
663
|
+
const height = buffer.readUInt32BE(20);
|
|
664
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
665
|
+
return { width, height };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 校验并规范化 PNG 字节:结构/CRC/尺寸、字节与像素预算、与 source.sha256 一致。
|
|
670
|
+
* 返回 {buffer, sha256, width, height, bytes}。
|
|
671
|
+
*/
|
|
672
|
+
function preparePng(pngBytes, source) {
|
|
673
|
+
const buffer = asBuffer(pngBytes);
|
|
674
|
+
if (!buffer || buffer.length === 0) invalidArgument('pngBytes 必须是非空 Buffer/Uint8Array', 'pngBytes');
|
|
675
|
+
if (buffer.length > BASELINE_LIMITS.maxPngBytes) {
|
|
676
|
+
limitExceeded('maxPngBytes', buffer.length, BASELINE_LIMITS.maxPngBytes);
|
|
677
|
+
}
|
|
678
|
+
// 先按 IHDR 声明尺寸判像素预算:超过 16M px 一律 LIMIT_EXCEEDED,
|
|
679
|
+
// 不让 inspectPng 的“异常尺寸”分支把资源超限误报成 INVALID_PNG。
|
|
680
|
+
const declared = pngDeclaredSize(buffer);
|
|
681
|
+
if (declared && declared.width * declared.height > BASELINE_LIMITS.maxPngPixels) {
|
|
682
|
+
limitExceeded('maxPngPixels', declared.width * declared.height, BASELINE_LIMITS.maxPngPixels);
|
|
683
|
+
}
|
|
684
|
+
const info = inspectPng(buffer, { checkCrc: true });
|
|
685
|
+
if (!info.ok) {
|
|
686
|
+
throw new BrowserError('INVALID_PNG', '基线图像不是合法 PNG', { reason: 'invalid_png' });
|
|
687
|
+
}
|
|
688
|
+
if (info.width * info.height > BASELINE_LIMITS.maxPngPixels) {
|
|
689
|
+
limitExceeded('maxPngPixels', info.width * info.height, BASELINE_LIMITS.maxPngPixels);
|
|
690
|
+
}
|
|
691
|
+
const copy = Buffer.from(buffer);
|
|
692
|
+
const sha256 = sha256Hex(copy);
|
|
693
|
+
if (source.sha256 !== sha256) {
|
|
694
|
+
invalidArgument('source.sha256 与 pngBytes 不一致', 'source.sha256');
|
|
695
|
+
}
|
|
696
|
+
return { buffer: copy, sha256, width: info.width, height: info.height, bytes: copy.length };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function buildHead(fields) {
|
|
700
|
+
return {
|
|
701
|
+
name: fields.name,
|
|
702
|
+
revision: fields.revision,
|
|
703
|
+
parentRevision: fields.parentRevision,
|
|
704
|
+
parentHeadToken: fields.parentHeadToken,
|
|
705
|
+
tombstone: fields.tombstone,
|
|
706
|
+
caseId: fields.caseId,
|
|
707
|
+
createdAt: fields.createdAt,
|
|
708
|
+
image: fields.image,
|
|
709
|
+
options: fields.options,
|
|
710
|
+
thresholds: fields.thresholds,
|
|
711
|
+
masks: fields.masks,
|
|
712
|
+
mapping: fields.mapping,
|
|
713
|
+
source: fields.source,
|
|
714
|
+
provenanceVersion: 1,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/* ----------------------------------------------------------------------------------------------
|
|
719
|
+
* 原子发布(wx 临时文件 + fsync + link no-clobber)
|
|
720
|
+
* -------------------------------------------------------------------------------------------- */
|
|
721
|
+
|
|
722
|
+
function writeAll(fd, bytes) {
|
|
723
|
+
let offset = 0;
|
|
724
|
+
while (offset < bytes.length) {
|
|
725
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
726
|
+
if (!Number.isInteger(written) || written <= 0) {
|
|
727
|
+
throw Object.assign(new Error('short write'), { code: 'EIO' });
|
|
728
|
+
}
|
|
729
|
+
offset += written;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** 返回 {ok:true} 或 {ok:false, exists:true};其它失败抛 WRITE_FAILED 并清理临时文件。 */
|
|
734
|
+
function publishFile(dir, targetPath, bytes, suffix) {
|
|
735
|
+
const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}${suffix}`);
|
|
736
|
+
let fd;
|
|
737
|
+
try {
|
|
738
|
+
fd = fs.openSync(tempPath, 'wx', 0o600);
|
|
739
|
+
writeAll(fd, bytes);
|
|
740
|
+
fs.fsyncSync(fd);
|
|
741
|
+
fs.closeSync(fd);
|
|
742
|
+
fd = undefined;
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (fd !== undefined) {
|
|
745
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
746
|
+
}
|
|
747
|
+
try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
|
|
748
|
+
writeFailureFrom(error, 'temp_write_failed');
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
fs.linkSync(tempPath, targetPath);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
|
|
754
|
+
if (error && (error.code === 'EEXIST' || error.code === 'EISDIR')) return { ok: false, exists: true };
|
|
755
|
+
writeFailureFrom(error, 'link_failed');
|
|
756
|
+
}
|
|
757
|
+
try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
|
|
758
|
+
return { ok: true };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function fsyncDirectory(dir) {
|
|
762
|
+
let fd;
|
|
763
|
+
try {
|
|
764
|
+
fd = fs.openSync(dir, 'r');
|
|
765
|
+
fs.fsyncSync(fd);
|
|
766
|
+
return { ok: true, code: null };
|
|
767
|
+
} catch (error) {
|
|
768
|
+
return { ok: false, code: error && error.code ? String(error.code) : null };
|
|
769
|
+
} finally {
|
|
770
|
+
if (fd !== undefined) {
|
|
771
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function unlinkQuietly(path) {
|
|
777
|
+
try { fs.unlinkSync(path); } catch { /* ignore */ }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** 发布 PNG(no-clobber);成功后 fsync images 目录(提交前,可安全回滚)。 */
|
|
781
|
+
function publishImage(nameDirs, name, revision, actionId, png) {
|
|
782
|
+
const fileName = `${revision}-${actionId}.png`;
|
|
783
|
+
const target = join(nameDirs.imagesDir, fileName);
|
|
784
|
+
const published = publishFile(nameDirs.imagesDir, target, png.buffer, '.png');
|
|
785
|
+
if (!published.ok) baselineConflict('image_exists', { name, revision });
|
|
786
|
+
const synced = fsyncDirectory(nameDirs.imagesDir);
|
|
787
|
+
if (!synced.ok && !TOLERATED_DIR_FSYNC.has(synced.code)) {
|
|
788
|
+
unlinkQuietly(target);
|
|
789
|
+
writeFailure('io', 'image_dir_fsync_failed', { name, revision, fsCode: synced.code });
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
fileName,
|
|
793
|
+
target,
|
|
794
|
+
meta: {
|
|
795
|
+
relativePath: `${name}/${IMAGES_SEGMENT}/${fileName}`,
|
|
796
|
+
sha256: png.sha256,
|
|
797
|
+
width: png.width,
|
|
798
|
+
height: png.height,
|
|
799
|
+
bytes: png.bytes,
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* 发布 head 文件(唯一提交点)。slotConflict='exists' 时 EEXIST 报 EXISTS(create),
|
|
806
|
+
* 否则报 CONFLICT(update/delete)。提交成功后目录 fsync 的真实 I/O 失败报
|
|
807
|
+
* WRITE_FAILED + details.committed=true,绝不删除已提交文件。
|
|
808
|
+
*/
|
|
809
|
+
function publishHead({ nameDirs, name, head, bytes, slotConflict }) {
|
|
810
|
+
const target = join(nameDirs.headsDir, headFileName(head.revision));
|
|
811
|
+
const published = publishFile(nameDirs.headsDir, target, bytes, '.json');
|
|
812
|
+
if (!published.ok) {
|
|
813
|
+
if (slotConflict === 'exists') baselineExists(name);
|
|
814
|
+
baselineConflict('head_slot_taken', { name, revision: head.revision });
|
|
815
|
+
}
|
|
816
|
+
const synced = fsyncDirectory(nameDirs.headsDir);
|
|
817
|
+
if (!synced.ok && !TOLERATED_DIR_FSYNC.has(synced.code)) {
|
|
818
|
+
writeFailure('io', 'head_dir_fsync_failed', {
|
|
819
|
+
name, revision: head.revision, committed: true, fsCode: synced.code,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return { ...head, headToken: sha256Hex(bytes) };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/* ----------------------------------------------------------------------------------------------
|
|
826
|
+
* 读取 / 父链校验
|
|
827
|
+
* -------------------------------------------------------------------------------------------- */
|
|
828
|
+
|
|
829
|
+
function readHeadBytes(path, name, revision) {
|
|
830
|
+
const info = lstatOrNull(path, 'read');
|
|
831
|
+
if (!info) readFailure('corrupt', 'head_missing', { name, revision });
|
|
832
|
+
if (info.isSymbolicLink()) readFailure('io', 'head_symlink', { name, revision });
|
|
833
|
+
if (!info.isFile()) readFailure('io', 'head_not_file', { name, revision });
|
|
834
|
+
if (info.size > BASELINE_LIMITS.maxHeadBytes) readFailure('corrupt', 'head_too_large', { name, revision });
|
|
835
|
+
try {
|
|
836
|
+
return fs.readFileSync(path);
|
|
837
|
+
} catch {
|
|
838
|
+
readFailure('io', 'head_read_failed', { name, revision });
|
|
839
|
+
}
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function parseHead(bytes, { name, revision, prevMeta }) {
|
|
844
|
+
const text = bytes.toString('utf8');
|
|
845
|
+
let parsed;
|
|
846
|
+
try {
|
|
847
|
+
parsed = JSON.parse(text);
|
|
848
|
+
} catch {
|
|
849
|
+
readFailure('corrupt', 'head_invalid_json', { name, revision });
|
|
850
|
+
}
|
|
851
|
+
if (!isPlainObject(parsed)) readFailure('corrupt', 'head_not_object', { name, revision });
|
|
852
|
+
const keys = Object.keys(parsed).sort();
|
|
853
|
+
if (keys.length !== HEAD_FIELDS.length || keys.some((key, index) => key !== HEAD_FIELDS[index])) {
|
|
854
|
+
readFailure('corrupt', 'head_unexpected_fields', { name, revision });
|
|
855
|
+
}
|
|
856
|
+
if (canonicalJson(parsed) !== text) readFailure('corrupt', 'head_not_canonical', { name, revision });
|
|
857
|
+
if (parsed.name !== name) readFailure('corrupt', 'head_name_mismatch', { name, revision });
|
|
858
|
+
if (parsed.revision !== revision) readFailure('corrupt', 'head_revision_mismatch', { name, revision });
|
|
859
|
+
if (typeof parsed.tombstone !== 'boolean') readFailure('corrupt', 'head_tombstone_invalid', { name, revision });
|
|
860
|
+
const caseId = readNameValue(parsed.caseId);
|
|
861
|
+
if (!caseId) readFailure('corrupt', 'head_case_id_invalid', { name, revision });
|
|
862
|
+
const createdAt = readInteger(parsed.createdAt, 0, MAX_SAFE);
|
|
863
|
+
if (createdAt === null) readFailure('corrupt', 'head_created_at_invalid', { name, revision });
|
|
864
|
+
if (parsed.provenanceVersion !== 1) readFailure('corrupt', 'head_provenance_invalid', { name, revision });
|
|
865
|
+
|
|
866
|
+
if (revision === 1) {
|
|
867
|
+
if (parsed.parentRevision !== null || parsed.parentHeadToken !== null) {
|
|
868
|
+
readFailure('corrupt', 'head_parent_invalid', { name, revision });
|
|
869
|
+
}
|
|
870
|
+
} else {
|
|
871
|
+
if (parsed.parentRevision !== revision - 1) readFailure('corrupt', 'head_parent_revision', { name, revision });
|
|
872
|
+
if (!prevMeta || parsed.parentHeadToken !== prevMeta.headToken) {
|
|
873
|
+
readFailure('corrupt', 'head_parent_token', { name, revision });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let image = null;
|
|
878
|
+
if (parsed.tombstone) {
|
|
879
|
+
if (parsed.image !== null) readFailure('corrupt', 'head_tombstone_image', { name, revision });
|
|
880
|
+
} else {
|
|
881
|
+
image = readImageMeta(parsed.image);
|
|
882
|
+
if (!image) readFailure('corrupt', 'head_image_invalid', { name, revision });
|
|
883
|
+
if (!imageFileName(image.relativePath, name, revision)) {
|
|
884
|
+
readFailure('corrupt', 'head_image_path', { name, revision });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const options = readOptionsFull(parsed.options);
|
|
889
|
+
if (!options) readFailure('corrupt', 'head_options_invalid', { name, revision });
|
|
890
|
+
const thresholds = readThresholdsFull(parsed.thresholds);
|
|
891
|
+
if (!thresholds) readFailure('corrupt', 'head_thresholds_invalid', { name, revision });
|
|
892
|
+
const masks = readMaskList(parsed.masks);
|
|
893
|
+
if (!masks) readFailure('corrupt', 'head_masks_invalid', { name, revision });
|
|
894
|
+
const source = readSourceSummary(parsed.source);
|
|
895
|
+
if (!source) readFailure('corrupt', 'head_source_invalid', { name, revision });
|
|
896
|
+
let mapping = null;
|
|
897
|
+
if (parsed.mapping !== null) {
|
|
898
|
+
if (!isPlainObject(parsed.mapping)) readFailure('corrupt', 'head_mapping_invalid', { name, revision });
|
|
899
|
+
mapping = canonicalize(parsed.mapping);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
name,
|
|
904
|
+
revision,
|
|
905
|
+
parentRevision: parsed.parentRevision,
|
|
906
|
+
parentHeadToken: parsed.parentHeadToken,
|
|
907
|
+
tombstone: parsed.tombstone,
|
|
908
|
+
caseId,
|
|
909
|
+
createdAt,
|
|
910
|
+
image,
|
|
911
|
+
options,
|
|
912
|
+
thresholds,
|
|
913
|
+
masks,
|
|
914
|
+
mapping,
|
|
915
|
+
source,
|
|
916
|
+
provenanceVersion: 1,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* 读取某个名字的完整 head 链:1..max 必须连续存在、规范、父链接与 hash 全部有效。
|
|
922
|
+
* 不跳过损坏记录复活旧 HEAD:任何缺号/损坏都报 READ_FAILED。
|
|
923
|
+
*/
|
|
924
|
+
function readChain(storeDirs, name) {
|
|
925
|
+
const nameDirs = resolveNameDirs(storeDirs, name, 'read');
|
|
926
|
+
if (nameDirs.state !== 'ready') return { state: nameDirs.state };
|
|
927
|
+
let entries;
|
|
928
|
+
try {
|
|
929
|
+
entries = fs.readdirSync(nameDirs.headsDir);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
readFailure('io', 'heads_readdir_failed', {
|
|
932
|
+
name, fsCode: error && error.code ? String(error.code) : null,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
const revisions = [];
|
|
936
|
+
for (const entry of entries) {
|
|
937
|
+
if (entry.startsWith(TEMP_PREFIX)) continue;
|
|
938
|
+
const match = HEAD_FILE_PATTERN.exec(entry);
|
|
939
|
+
if (match) {
|
|
940
|
+
revisions.push(Number(match[1]));
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (entry.endsWith('.json')) readFailure('corrupt', 'head_name_invalid', { name });
|
|
944
|
+
}
|
|
945
|
+
if (revisions.length === 0) return { state: 'uncommitted' };
|
|
946
|
+
revisions.sort((a, b) => a - b);
|
|
947
|
+
if (revisions[0] !== 1) readFailure('corrupt', 'head_chain_start', { name });
|
|
948
|
+
for (let index = 0; index < revisions.length; index += 1) {
|
|
949
|
+
if (revisions[index] !== index + 1) readFailure('corrupt', 'head_chain_gap', { name });
|
|
950
|
+
}
|
|
951
|
+
const max = revisions[revisions.length - 1];
|
|
952
|
+
const metas = [];
|
|
953
|
+
let prevMeta = null;
|
|
954
|
+
for (let revision = 1; revision <= max; revision += 1) {
|
|
955
|
+
const bytes = readHeadBytes(join(nameDirs.headsDir, headFileName(revision)), name, revision);
|
|
956
|
+
const meta = parseHead(bytes, { name, revision, prevMeta });
|
|
957
|
+
metas.push({ ...meta, headToken: sha256Hex(bytes) });
|
|
958
|
+
prevMeta = metas[metas.length - 1];
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
state: 'ready',
|
|
962
|
+
dirs: nameDirs,
|
|
963
|
+
metas,
|
|
964
|
+
head: metas[metas.length - 1],
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function readChainFor(name) {
|
|
969
|
+
const storeDirs = resolveStore({ write: false });
|
|
970
|
+
if (storeDirs.state === 'absent') return { state: 'absent' };
|
|
971
|
+
return readChain(storeDirs, name);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** 读取并验证某个非墓碑 revision 的 PNG 字节(缺失/符号链接/大小或 hash 不符都报 READ_FAILED)。 */
|
|
975
|
+
function readPngBytes(chain, meta) {
|
|
976
|
+
const name = meta.name;
|
|
977
|
+
const revision = meta.revision;
|
|
978
|
+
const fileName = imageFileName(meta.image.relativePath, name, revision);
|
|
979
|
+
if (!fileName) readFailure('corrupt', 'image_path_invalid', { name, revision });
|
|
980
|
+
const imagesDir = chain.dirs.imagesDir;
|
|
981
|
+
const dirInfo = lstatOrNull(imagesDir, 'read');
|
|
982
|
+
if (!dirInfo) readFailure('io', 'png_missing', { name, revision });
|
|
983
|
+
if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) readFailure('io', 'images_dir_invalid', { name, revision });
|
|
984
|
+
realpathChecked(imagesDir, 'read');
|
|
985
|
+
const pngPath = join(imagesDir, fileName);
|
|
986
|
+
const info = lstatOrNull(pngPath, 'read');
|
|
987
|
+
if (!info) readFailure('io', 'png_missing', { name, revision });
|
|
988
|
+
if (info.isSymbolicLink()) readFailure('io', 'png_symlink', { name, revision });
|
|
989
|
+
if (!info.isFile()) readFailure('io', 'png_not_file', { name, revision });
|
|
990
|
+
let bytes;
|
|
991
|
+
try {
|
|
992
|
+
bytes = fs.readFileSync(pngPath);
|
|
993
|
+
} catch {
|
|
994
|
+
readFailure('io', 'png_read_failed', { name, revision });
|
|
995
|
+
}
|
|
996
|
+
if (bytes.length !== meta.image.bytes || sha256Hex(bytes) !== meta.image.sha256) {
|
|
997
|
+
readFailure('hash-mismatch', 'png_hash_mismatch', { name, revision });
|
|
998
|
+
}
|
|
999
|
+
return bytes;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/* ----------------------------------------------------------------------------------------------
|
|
1003
|
+
* 公共操作
|
|
1004
|
+
* -------------------------------------------------------------------------------------------- */
|
|
1005
|
+
|
|
1006
|
+
/** list:每个名字最新 HEAD(含 tombstone),名字字典序,≤1000;store 目录不存在返回 []。 */
|
|
1007
|
+
function list() {
|
|
1008
|
+
const storeDirs = resolveStore({ write: false });
|
|
1009
|
+
if (storeDirs.state === 'absent') return { ok: true, baselines: [] };
|
|
1010
|
+
let entries;
|
|
1011
|
+
try {
|
|
1012
|
+
entries = fs.readdirSync(storeDirs.storeDir);
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
readFailure('io', 'store_readdir_failed', {
|
|
1015
|
+
fsCode: error && error.code ? String(error.code) : null,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
const names = entries.filter((entry) => NAME_PATTERN.test(entry)).sort();
|
|
1019
|
+
if (names.length > BASELINE_LIMITS.maxListEntries) {
|
|
1020
|
+
limitExceeded('maxListEntries', names.length, BASELINE_LIMITS.maxListEntries);
|
|
1021
|
+
}
|
|
1022
|
+
const baselines = [];
|
|
1023
|
+
for (const name of names) {
|
|
1024
|
+
const chain = readChain(storeDirs, name);
|
|
1025
|
+
if (chain.state !== 'ready') continue;
|
|
1026
|
+
baselines.push(chain.head);
|
|
1027
|
+
}
|
|
1028
|
+
return { ok: true, baselines };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* get(name, revision?):默认逻辑 HEAD(tombstone HEAD → NOT_FOUND);
|
|
1033
|
+
* 显式旧 revision 可审计读取,恰为墓碑时成功返回 {meta, pngBytes:null}。
|
|
1034
|
+
*/
|
|
1035
|
+
function get(name, revision) {
|
|
1036
|
+
const parsedName = assertName(name);
|
|
1037
|
+
let requested = null;
|
|
1038
|
+
if (revision !== undefined && revision !== null) requested = assertRevision(revision, 'revision');
|
|
1039
|
+
const chain = readChainFor(parsedName);
|
|
1040
|
+
if (chain.state !== 'ready') notFound(parsedName, requested === null ? undefined : requested);
|
|
1041
|
+
if (requested === null) {
|
|
1042
|
+
if (chain.head.tombstone) notFound(parsedName);
|
|
1043
|
+
return { ok: true, meta: chain.head, pngBytes: readPngBytes(chain, chain.head) };
|
|
1044
|
+
}
|
|
1045
|
+
if (requested > chain.head.revision) notFound(parsedName, requested);
|
|
1046
|
+
const meta = chain.metas[requested - 1];
|
|
1047
|
+
if (!meta) notFound(parsedName, requested);
|
|
1048
|
+
if (meta.tombstone) return { ok: true, meta, pngBytes: null };
|
|
1049
|
+
return { ok: true, meta, pngBytes: readPngBytes(chain, meta) };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function normalizeCreate(input) {
|
|
1053
|
+
if (!isPlainObject(input)) invalidArgument('create 参数必须是对象', 'input');
|
|
1054
|
+
const name = assertName(input.name);
|
|
1055
|
+
const actionId = assertActionId(input.actionId);
|
|
1056
|
+
const source = normalizeSource(input.source);
|
|
1057
|
+
const png = preparePng(input.pngBytes, source);
|
|
1058
|
+
const caseId = input.caseId === undefined ? name : assertCaseId(input.caseId);
|
|
1059
|
+
const options = resolveOptions(input.options, null);
|
|
1060
|
+
const thresholds = resolveThresholds(input.thresholds, null);
|
|
1061
|
+
const masks = resolveMasks(input.masks, null);
|
|
1062
|
+
const mapping = normalizeMapping(input.mapping);
|
|
1063
|
+
return { name, actionId, source, png, caseId, options, thresholds, masks, mapping };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function normalizeUpdate(input) {
|
|
1067
|
+
if (!isPlainObject(input)) invalidArgument('update 参数必须是对象', 'input');
|
|
1068
|
+
const name = assertName(input.name);
|
|
1069
|
+
const actionId = assertActionId(input.actionId);
|
|
1070
|
+
const expectedRevision = assertRevision(input.expectedRevision, 'expectedRevision');
|
|
1071
|
+
const source = normalizeSource(input.source);
|
|
1072
|
+
const png = preparePng(input.pngBytes, source);
|
|
1073
|
+
const caseId = input.caseId === undefined ? undefined : assertCaseId(input.caseId);
|
|
1074
|
+
if (input.options !== undefined && !readOptionsPatch(input.options)) {
|
|
1075
|
+
invalidArgument('options 非法', 'options');
|
|
1076
|
+
}
|
|
1077
|
+
if (input.thresholds !== undefined && !readThresholdsPatch(input.thresholds)) {
|
|
1078
|
+
invalidArgument('thresholds 非法', 'thresholds');
|
|
1079
|
+
}
|
|
1080
|
+
if (input.masks !== undefined && !readMaskList(input.masks)) {
|
|
1081
|
+
invalidArgument('masks 非法', 'masks');
|
|
1082
|
+
}
|
|
1083
|
+
const mapping = normalizeMapping(input.mapping);
|
|
1084
|
+
return { name, actionId, expectedRevision, source, png, caseId, mapping,
|
|
1085
|
+
optionsPatch: input.options, thresholdsPatch: input.thresholds, masksPatch: input.masks };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** 名字是否已被提交使用(含 tombstone);崩溃遗留的 images/临时文件不占用名字。 */
|
|
1089
|
+
function nameInUse(storeDirs, name) {
|
|
1090
|
+
const nameDir = join(storeDirs.storeDir, name);
|
|
1091
|
+
const nameInfo = lstatOrNull(nameDir, 'write');
|
|
1092
|
+
if (!nameInfo) return false;
|
|
1093
|
+
assertDirInfo(nameInfo, nameDir, 'name', 'write');
|
|
1094
|
+
const headsDir = join(nameDir, HEADS_SEGMENT);
|
|
1095
|
+
const headsInfo = lstatOrNull(headsDir, 'write');
|
|
1096
|
+
if (!headsInfo) return false;
|
|
1097
|
+
assertDirInfo(headsInfo, headsDir, 'heads', 'write');
|
|
1098
|
+
let entries;
|
|
1099
|
+
try {
|
|
1100
|
+
entries = fs.readdirSync(headsDir);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
writeFailureFrom(error, 'heads_readdir_failed');
|
|
1103
|
+
}
|
|
1104
|
+
return entries.some((entry) => !entry.startsWith(TEMP_PREFIX));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** create:名字不可复用(含 tombstone);只在这里/update 建目录。 */
|
|
1108
|
+
function create(input) {
|
|
1109
|
+
const params = normalizeCreate(input);
|
|
1110
|
+
// 先取时间:注入时钟非法时必须在任何目录/PNG 副作用之前失败。
|
|
1111
|
+
const createdAt = clock();
|
|
1112
|
+
|
|
1113
|
+
const existing = resolveStore({ write: true });
|
|
1114
|
+
if (existing.state === 'ready' && nameInUse(existing, params.name)) baselineExists(params.name);
|
|
1115
|
+
|
|
1116
|
+
const storeDirs = resolveStore({ write: true, create: true });
|
|
1117
|
+
const nameDirs = ensureNameDirs(storeDirs, params.name);
|
|
1118
|
+
const image = publishImage(nameDirs, params.name, 1, params.actionId, params.png);
|
|
1119
|
+
|
|
1120
|
+
const head = buildHead({
|
|
1121
|
+
name: params.name,
|
|
1122
|
+
revision: 1,
|
|
1123
|
+
parentRevision: null,
|
|
1124
|
+
parentHeadToken: null,
|
|
1125
|
+
tombstone: false,
|
|
1126
|
+
caseId: params.caseId,
|
|
1127
|
+
createdAt,
|
|
1128
|
+
image: image.meta,
|
|
1129
|
+
options: params.options,
|
|
1130
|
+
thresholds: params.thresholds,
|
|
1131
|
+
masks: params.masks,
|
|
1132
|
+
mapping: params.mapping.provided ? params.mapping.value : null,
|
|
1133
|
+
source: params.source,
|
|
1134
|
+
});
|
|
1135
|
+
const bytes = canonicalJson(head);
|
|
1136
|
+
try {
|
|
1137
|
+
return publishHead({ nameDirs, name: params.name, head, bytes, slotConflict: 'exists' });
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
if (!error || !error.details || error.details.committed !== true) unlinkQuietly(image.target);
|
|
1140
|
+
throw error;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** update:CAS(expectedRevision===HEAD) → 写不可变 PNG → 再读 parent → link 抢下一 head 槽。 */
|
|
1145
|
+
function update(input) {
|
|
1146
|
+
const params = normalizeUpdate(input);
|
|
1147
|
+
|
|
1148
|
+
const chain = readChainFor(params.name);
|
|
1149
|
+
if (chain.state !== 'ready') notFound(params.name, params.expectedRevision);
|
|
1150
|
+
const head = chain.head;
|
|
1151
|
+
if (head.tombstone) notFound(params.name);
|
|
1152
|
+
if (head.revision !== params.expectedRevision) {
|
|
1153
|
+
baselineConflict('expected_revision_mismatch', {
|
|
1154
|
+
name: params.name, expectedRevision: params.expectedRevision, headRevision: head.revision,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
const nextRevision = head.revision + 1;
|
|
1158
|
+
if (nextRevision > BASELINE_LIMITS.maxRevision) {
|
|
1159
|
+
limitExceeded('maxRevision', nextRevision, BASELINE_LIMITS.maxRevision);
|
|
1160
|
+
}
|
|
1161
|
+
const createdAt = clock();
|
|
1162
|
+
|
|
1163
|
+
const resolved = {
|
|
1164
|
+
caseId: params.caseId === undefined ? head.caseId : params.caseId,
|
|
1165
|
+
options: resolveOptions(params.optionsPatch, head.options),
|
|
1166
|
+
thresholds: resolveThresholds(params.thresholdsPatch, head.thresholds),
|
|
1167
|
+
masks: resolveMasks(params.masksPatch, head.masks),
|
|
1168
|
+
mapping: params.mapping.provided ? params.mapping.value : cloneJson(head.mapping),
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
const nameDirs = ensureExistingNameDirs(chain.dirs);
|
|
1172
|
+
const image = publishImage(nameDirs, params.name, nextRevision, params.actionId, params.png);
|
|
1173
|
+
|
|
1174
|
+
// 发布前再次读 parent:确认父链没有被并发写者推进(link 仍可能输,见 publishHead)。
|
|
1175
|
+
// 复验本身失败(并发损坏/读失败)也属于提交前失败:清理本次未引用 PNG 后再抛。
|
|
1176
|
+
let again;
|
|
1177
|
+
try {
|
|
1178
|
+
again = readChainFor(params.name);
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
unlinkQuietly(image.target);
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
if (again.state !== 'ready' || again.head.revision !== head.revision || again.head.headToken !== head.headToken) {
|
|
1184
|
+
unlinkQuietly(image.target);
|
|
1185
|
+
baselineConflict('parent_changed', { name: params.name, expectedRevision: head.revision });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const newHead = buildHead({
|
|
1189
|
+
name: params.name,
|
|
1190
|
+
revision: nextRevision,
|
|
1191
|
+
parentRevision: head.revision,
|
|
1192
|
+
parentHeadToken: head.headToken,
|
|
1193
|
+
tombstone: false,
|
|
1194
|
+
caseId: resolved.caseId,
|
|
1195
|
+
createdAt,
|
|
1196
|
+
image: image.meta,
|
|
1197
|
+
options: resolved.options,
|
|
1198
|
+
thresholds: resolved.thresholds,
|
|
1199
|
+
masks: resolved.masks,
|
|
1200
|
+
mapping: resolved.mapping,
|
|
1201
|
+
source: params.source,
|
|
1202
|
+
});
|
|
1203
|
+
const bytes = canonicalJson(newHead);
|
|
1204
|
+
try {
|
|
1205
|
+
return publishHead({ nameDirs: again.dirs, name: params.name, head: newHead, bytes, slotConflict: 'conflict' });
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (!error || !error.details || error.details.committed !== true) unlinkQuietly(image.target);
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** delete:不建目录、不删 PNG;追加 tombstone HEAD(CAS)。 */
|
|
1213
|
+
function remove(input) {
|
|
1214
|
+
if (!isPlainObject(input)) invalidArgument('delete 参数必须是对象', 'input');
|
|
1215
|
+
const name = assertName(input.name);
|
|
1216
|
+
const expectedRevision = assertRevision(input.expectedRevision, 'expectedRevision');
|
|
1217
|
+
if (input.actionId !== undefined && input.actionId !== null) assertActionId(input.actionId);
|
|
1218
|
+
|
|
1219
|
+
const chain = readChainFor(name);
|
|
1220
|
+
if (chain.state !== 'ready') notFound(name, expectedRevision);
|
|
1221
|
+
const head = chain.head;
|
|
1222
|
+
if (head.tombstone) notFound(name);
|
|
1223
|
+
if (head.revision !== expectedRevision) {
|
|
1224
|
+
baselineConflict('expected_revision_mismatch', {
|
|
1225
|
+
name, expectedRevision, headRevision: head.revision,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
const nextRevision = head.revision + 1;
|
|
1229
|
+
if (nextRevision > BASELINE_LIMITS.maxRevision) {
|
|
1230
|
+
limitExceeded('maxRevision', nextRevision, BASELINE_LIMITS.maxRevision);
|
|
1231
|
+
}
|
|
1232
|
+
const createdAt = clock();
|
|
1233
|
+
|
|
1234
|
+
const tombstone = buildHead({
|
|
1235
|
+
name,
|
|
1236
|
+
revision: nextRevision,
|
|
1237
|
+
parentRevision: head.revision,
|
|
1238
|
+
parentHeadToken: head.headToken,
|
|
1239
|
+
tombstone: true,
|
|
1240
|
+
caseId: head.caseId,
|
|
1241
|
+
createdAt,
|
|
1242
|
+
image: null,
|
|
1243
|
+
options: cloneJson(head.options),
|
|
1244
|
+
thresholds: cloneJson(head.thresholds),
|
|
1245
|
+
masks: cloneJson(head.masks),
|
|
1246
|
+
mapping: cloneJson(head.mapping),
|
|
1247
|
+
source: cloneJson(head.source),
|
|
1248
|
+
});
|
|
1249
|
+
const bytes = canonicalJson(tombstone);
|
|
1250
|
+
return publishHead({ nameDirs: chain.dirs, name, head: tombstone, bytes, slotConflict: 'conflict' });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return { scopeHash, list, get, create, update, delete: remove };
|
|
1254
|
+
}
|