mirai-graph 1.2.0 → 1.3.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 +30 -0
- package/README.md +16 -2
- package/docs/adoption/cli.md +13 -0
- package/package.json +39 -38
- package/packages/cli/context-pack.js +0 -0
- package/packages/cli/mirai-graph.js +1 -1
- package/packages/cli/mirai_graph.js +0 -0
- package/packages/cli/project-technology.js +18 -2
- package/packages/cli/readiness-score.js +0 -0
- package/packages/cli/seed-preview.js +0 -0
- package/packages/cli/validate-artifact-releases.js +282 -0
- package/packages/cli/validate-mirai-graph.js +0 -0
- package/packages/cli/validate-profile-results.js +0 -0
- package/packages/project-technology/artifact-release.js +738 -0
- package/packages/project-technology/index.js +6 -0
- package/releases/1.3.0.md +16 -0
- package/releases/README.md +1 -0
- package/standard/project-technology.md +45 -1
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const zlib = require("zlib");
|
|
8
|
+
|
|
9
|
+
const REGISTRY_FILE = path.join("graph", "specs", "artifact-releases.json");
|
|
10
|
+
const DEFAULT_ROOT = path.join("artifacts", "matters");
|
|
11
|
+
const RELEASE_ID_RE = /^\d{8}-\d{2}$/;
|
|
12
|
+
const MATTER_ID_RE = /^[a-z0-9][a-z0-9._-]{1,79}$/;
|
|
13
|
+
const SAFE_DIRECTIONS = new Set(["inbound", "internal", "outbound"]);
|
|
14
|
+
const BLOCKED_EXTENSIONS = new Set([
|
|
15
|
+
".app", ".bat", ".cmd", ".com", ".dll", ".dmg", ".exe", ".hta", ".jar",
|
|
16
|
+
".js", ".jse", ".lnk", ".msi", ".pkg", ".ps1", ".scr", ".sh", ".vbs",
|
|
17
|
+
".docm", ".dotm", ".xlsm", ".xltm", ".pptm", ".potm", ".ppsm",
|
|
18
|
+
]);
|
|
19
|
+
const OS_NOISE = new Set([".DS_Store", "Thumbs.db"]);
|
|
20
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
21
|
+
maxFiles: 1000,
|
|
22
|
+
maxTotalBytes: 200 * 1024 * 1024,
|
|
23
|
+
maxSingleBytes: 100 * 1024 * 1024,
|
|
24
|
+
maxCompressionRatio: 100,
|
|
25
|
+
maxArchiveDepth: 1,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function sortValue(value) {
|
|
29
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
30
|
+
if (!value || typeof value !== "object") return value;
|
|
31
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonicalBytes(value) {
|
|
35
|
+
return `${JSON.stringify(sortValue(value), null, 2)}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sha256(value, prefix = false) {
|
|
39
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value));
|
|
40
|
+
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
41
|
+
return prefix ? `sha256:${digest}` : digest;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
|
|
45
|
+
let value = index;
|
|
46
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
|
|
47
|
+
return value >>> 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function crc32(bytes) {
|
|
51
|
+
let value = 0xffffffff;
|
|
52
|
+
for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
|
|
53
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function result(action, mode, status, extra = {}) {
|
|
57
|
+
return {
|
|
58
|
+
schema_version: "1.0.0",
|
|
59
|
+
operation_id: `mirai.project_technology.artifact.${action}`,
|
|
60
|
+
operation_mode: mode,
|
|
61
|
+
status,
|
|
62
|
+
changed: false,
|
|
63
|
+
blockers: [],
|
|
64
|
+
warnings: [],
|
|
65
|
+
next_action: "none",
|
|
66
|
+
...extra,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeRepository(repository) {
|
|
71
|
+
return path.resolve(repository || ".");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeRelative(input) {
|
|
75
|
+
if (typeof input !== "string" || !input.trim() || /[\u0000-\u001f]/.test(input)) return null;
|
|
76
|
+
const replaced = input.replace(/\\/g, "/").normalize("NFC");
|
|
77
|
+
if (replaced.startsWith("/") || /^[A-Za-z]:\//.test(replaced)) return null;
|
|
78
|
+
const normalized = path.posix.normalize(replaced).replace(/^\.\//, "");
|
|
79
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) return null;
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeMatterId(value) {
|
|
84
|
+
return typeof value === "string" && MATTER_ID_RE.test(value) ? value : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function limits(options = {}) {
|
|
88
|
+
const output = { ...DEFAULT_LIMITS };
|
|
89
|
+
for (const key of Object.keys(output)) {
|
|
90
|
+
if (Number.isInteger(options[key]) && options[key] > 0 && options[key] <= DEFAULT_LIMITS[key]) output[key] = options[key];
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extensionBlocked(relative) {
|
|
96
|
+
return BLOCKED_EXTENSIONS.has(path.extname(relative).toLowerCase());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nestedArchive(relative) {
|
|
100
|
+
const lower = relative.toLowerCase();
|
|
101
|
+
return lower.endsWith(".zip") || lower.endsWith(".tar") || lower.endsWith(".tar.gz") ||
|
|
102
|
+
lower.endsWith(".tgz") || lower.endsWith(".rar") || lower.endsWith(".7z");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ignoreNoise(relative) {
|
|
106
|
+
const parts = relative.split("/");
|
|
107
|
+
return parts[0] === "__MACOSX" || OS_NOISE.has(parts[parts.length - 1]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateEntries(entries, inputLimits) {
|
|
111
|
+
const blockers = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let totalBytes = 0;
|
|
114
|
+
if (entries.length > inputLimits.maxFiles) blockers.push("artifact_file_count_limit_exceeded");
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const relative = safeRelative(entry.path);
|
|
117
|
+
if (!relative) {
|
|
118
|
+
blockers.push("artifact_path_unsafe");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const identity = relative.toLocaleLowerCase("en-US");
|
|
122
|
+
if (seen.has(identity)) blockers.push("artifact_normalized_path_duplicate");
|
|
123
|
+
seen.add(identity);
|
|
124
|
+
const size = Number(entry.bytes?.length ?? entry.size ?? 0);
|
|
125
|
+
totalBytes += size;
|
|
126
|
+
if (size > inputLimits.maxSingleBytes) blockers.push("artifact_single_file_limit_exceeded");
|
|
127
|
+
if (extensionBlocked(relative)) blockers.push("artifact_executable_or_macro_blocked");
|
|
128
|
+
if (nestedArchive(relative)) blockers.push("artifact_nested_archive_blocked");
|
|
129
|
+
}
|
|
130
|
+
if (totalBytes > inputLimits.maxTotalBytes) blockers.push("artifact_total_size_limit_exceeded");
|
|
131
|
+
return { blockers: [...new Set(blockers)].sort(), totalBytes };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function walkDirectory(root, current = root, output = []) {
|
|
135
|
+
for (const name of fs.readdirSync(current).sort()) {
|
|
136
|
+
const absolute = path.join(current, name);
|
|
137
|
+
const stat = fs.lstatSync(absolute);
|
|
138
|
+
if (stat.isSymbolicLink()) throw new Error("artifact_symlink_blocked");
|
|
139
|
+
if (stat.isDirectory()) walkDirectory(root, absolute, output);
|
|
140
|
+
else if (stat.isFile()) output.push({
|
|
141
|
+
path: path.relative(root, absolute).split(path.sep).join("/"),
|
|
142
|
+
bytes: fs.readFileSync(absolute),
|
|
143
|
+
});
|
|
144
|
+
else throw new Error("artifact_special_file_blocked");
|
|
145
|
+
}
|
|
146
|
+
return output;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function zipRecords(bytes, inputLimits) {
|
|
150
|
+
const blockers = [];
|
|
151
|
+
const records = [];
|
|
152
|
+
let offset = 0;
|
|
153
|
+
let compressedTotal = 0;
|
|
154
|
+
let uncompressedTotal = 0;
|
|
155
|
+
let count = 0;
|
|
156
|
+
while (offset + 46 <= bytes.length) {
|
|
157
|
+
if (bytes.readUInt32LE(offset) !== 0x02014b50) {
|
|
158
|
+
offset += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const flags = bytes.readUInt16LE(offset + 8);
|
|
162
|
+
const compressed = bytes.readUInt32LE(offset + 20);
|
|
163
|
+
const uncompressed = bytes.readUInt32LE(offset + 24);
|
|
164
|
+
const fileNameLength = bytes.readUInt16LE(offset + 28);
|
|
165
|
+
const extraLength = bytes.readUInt16LE(offset + 30);
|
|
166
|
+
const commentLength = bytes.readUInt16LE(offset + 32);
|
|
167
|
+
const externalAttributes = bytes.readUInt32LE(offset + 38);
|
|
168
|
+
const localOffset = bytes.readUInt32LE(offset + 42);
|
|
169
|
+
const method = bytes.readUInt16LE(offset + 10);
|
|
170
|
+
const madeBy = bytes.readUInt16LE(offset + 4) >> 8;
|
|
171
|
+
const fileName = bytes.subarray(offset + 46, offset + 46 + fileNameLength).toString("utf8");
|
|
172
|
+
if (flags & 1) blockers.push("artifact_archive_encrypted");
|
|
173
|
+
if (madeBy === 3 && (((externalAttributes >>> 16) & 0o170000) === 0o120000)) blockers.push("artifact_symlink_blocked");
|
|
174
|
+
compressedTotal += compressed;
|
|
175
|
+
uncompressedTotal += uncompressed;
|
|
176
|
+
count += 1;
|
|
177
|
+
records.push({ fileName, flags, method, compressed, uncompressed, localOffset });
|
|
178
|
+
offset += 46 + fileNameLength + extraLength + commentLength;
|
|
179
|
+
}
|
|
180
|
+
if (!count) blockers.push("artifact_zip_central_directory_missing");
|
|
181
|
+
if (count > inputLimits.maxFiles) blockers.push("artifact_file_count_limit_exceeded");
|
|
182
|
+
if (uncompressedTotal > inputLimits.maxTotalBytes) blockers.push("artifact_total_size_limit_exceeded");
|
|
183
|
+
if (uncompressedTotal && uncompressedTotal / Math.max(1, compressedTotal) > inputLimits.maxCompressionRatio) blockers.push("artifact_compression_ratio_limit_exceeded");
|
|
184
|
+
return { blockers: [...new Set(blockers)].sort(), records };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseZip(bytes, inputLimits) {
|
|
188
|
+
const parsed = zipRecords(bytes, inputLimits);
|
|
189
|
+
const blockers = parsed.blockers;
|
|
190
|
+
if (blockers.length) return { entries: [], blockers };
|
|
191
|
+
const entries = [];
|
|
192
|
+
try {
|
|
193
|
+
for (const record of parsed.records) {
|
|
194
|
+
if (record.fileName.endsWith("/")) continue;
|
|
195
|
+
if (bytes.readUInt32LE(record.localOffset) !== 0x04034b50) throw new Error("invalid local header");
|
|
196
|
+
const nameLength = bytes.readUInt16LE(record.localOffset + 26);
|
|
197
|
+
const extraLength = bytes.readUInt16LE(record.localOffset + 28);
|
|
198
|
+
const start = record.localOffset + 30 + nameLength + extraLength;
|
|
199
|
+
const compressed = bytes.subarray(start, start + record.compressed);
|
|
200
|
+
if (compressed.length !== record.compressed) throw new Error("truncated data");
|
|
201
|
+
let content;
|
|
202
|
+
if (record.method === 0) content = Buffer.from(compressed);
|
|
203
|
+
else if (record.method === 8) content = zlib.inflateRawSync(compressed, { maxOutputLength: inputLimits.maxSingleBytes + 1 });
|
|
204
|
+
else throw new Error("unsupported compression");
|
|
205
|
+
if (content.length !== record.uncompressed) throw new Error("size mismatch");
|
|
206
|
+
entries.push({ path: record.fileName, bytes: content });
|
|
207
|
+
}
|
|
208
|
+
} catch (_) {
|
|
209
|
+
return { entries: [], blockers: ["artifact_zip_invalid_or_compression_unsupported"] };
|
|
210
|
+
}
|
|
211
|
+
const normalized = entries.filter((entry) => !ignoreNoise(entry.path));
|
|
212
|
+
return { entries: normalized, blockers: validateEntries(normalized, inputLimits).blockers };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function createZip(files) {
|
|
216
|
+
const localParts = [];
|
|
217
|
+
const centralParts = [];
|
|
218
|
+
let offset = 0;
|
|
219
|
+
for (const [name, raw] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
|
220
|
+
const nameBytes = Buffer.from(name);
|
|
221
|
+
const bytes = Buffer.from(raw);
|
|
222
|
+
const compressed = zlib.deflateRawSync(bytes, { level: 6 });
|
|
223
|
+
const checksum = crc32(bytes);
|
|
224
|
+
const local = Buffer.alloc(30);
|
|
225
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
226
|
+
local.writeUInt16LE(20, 4);
|
|
227
|
+
local.writeUInt16LE(0x0800, 6);
|
|
228
|
+
local.writeUInt16LE(8, 8);
|
|
229
|
+
local.writeUInt16LE(0, 10);
|
|
230
|
+
local.writeUInt16LE(0x0021, 12);
|
|
231
|
+
local.writeUInt32LE(checksum, 14);
|
|
232
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
233
|
+
local.writeUInt32LE(bytes.length, 22);
|
|
234
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
235
|
+
local.writeUInt16LE(0, 28);
|
|
236
|
+
localParts.push(local, nameBytes, compressed);
|
|
237
|
+
const central = Buffer.alloc(46);
|
|
238
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
239
|
+
central.writeUInt16LE(0x0314, 4);
|
|
240
|
+
central.writeUInt16LE(20, 6);
|
|
241
|
+
central.writeUInt16LE(0x0800, 8);
|
|
242
|
+
central.writeUInt16LE(8, 10);
|
|
243
|
+
central.writeUInt16LE(0, 12);
|
|
244
|
+
central.writeUInt16LE(0x0021, 14);
|
|
245
|
+
central.writeUInt32LE(checksum, 16);
|
|
246
|
+
central.writeUInt32LE(compressed.length, 20);
|
|
247
|
+
central.writeUInt32LE(bytes.length, 24);
|
|
248
|
+
central.writeUInt16LE(nameBytes.length, 28);
|
|
249
|
+
central.writeUInt16LE(0, 30);
|
|
250
|
+
central.writeUInt16LE(0, 32);
|
|
251
|
+
central.writeUInt16LE(0, 34);
|
|
252
|
+
central.writeUInt16LE(0, 36);
|
|
253
|
+
central.writeUInt32LE((0o100600 << 16) >>> 0, 38);
|
|
254
|
+
central.writeUInt32LE(offset, 42);
|
|
255
|
+
centralParts.push(central, nameBytes);
|
|
256
|
+
offset += local.length + nameBytes.length + compressed.length;
|
|
257
|
+
}
|
|
258
|
+
const centralBytes = Buffer.concat(centralParts);
|
|
259
|
+
const end = Buffer.alloc(22);
|
|
260
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
261
|
+
const count = Object.keys(files).length;
|
|
262
|
+
end.writeUInt16LE(count, 8);
|
|
263
|
+
end.writeUInt16LE(count, 10);
|
|
264
|
+
end.writeUInt32LE(centralBytes.length, 12);
|
|
265
|
+
end.writeUInt32LE(offset, 16);
|
|
266
|
+
return Buffer.concat([...localParts, centralBytes, end]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseTar(bytes, inputLimits) {
|
|
270
|
+
const entries = [];
|
|
271
|
+
const blockers = [];
|
|
272
|
+
let offset = 0;
|
|
273
|
+
while (offset + 512 <= bytes.length) {
|
|
274
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
275
|
+
if (header.every((byte) => byte === 0)) break;
|
|
276
|
+
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
277
|
+
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
|
|
278
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
279
|
+
const rawSize = header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim();
|
|
280
|
+
const size = rawSize ? Number.parseInt(rawSize, 8) : 0;
|
|
281
|
+
const type = String.fromCharCode(header[156] || 0);
|
|
282
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
283
|
+
blockers.push("artifact_tar_invalid");
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
const dataStart = offset + 512;
|
|
287
|
+
const dataEnd = dataStart + size;
|
|
288
|
+
if (dataEnd > bytes.length) {
|
|
289
|
+
blockers.push("artifact_tar_invalid");
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
if (type === "1" || type === "2") blockers.push("artifact_symlink_or_hardlink_blocked");
|
|
293
|
+
else if (type === "0" || type === "\0") entries.push({ path: relative, bytes: Buffer.from(bytes.subarray(dataStart, dataEnd)) });
|
|
294
|
+
else if (type !== "5") blockers.push("artifact_tar_entry_type_unsupported");
|
|
295
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
296
|
+
}
|
|
297
|
+
const filtered = entries.filter((entry) => !ignoreNoise(entry.path));
|
|
298
|
+
blockers.push(...validateEntries(filtered, inputLimits).blockers);
|
|
299
|
+
return { entries: filtered, blockers: [...new Set(blockers)].sort() };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function inspectInput(inputPath, options = {}) {
|
|
303
|
+
const inputLimits = limits(options);
|
|
304
|
+
const absolute = path.resolve(String(inputPath || ""));
|
|
305
|
+
if (!inputPath || !fs.existsSync(absolute)) return { blockers: ["artifact_input_missing"], entries: [] };
|
|
306
|
+
const stat = fs.lstatSync(absolute);
|
|
307
|
+
if (stat.isSymbolicLink()) return { blockers: ["artifact_input_symlink_blocked"], entries: [] };
|
|
308
|
+
let entries = [];
|
|
309
|
+
let blockers = [];
|
|
310
|
+
let inputType = "files";
|
|
311
|
+
let originalBytes = null;
|
|
312
|
+
try {
|
|
313
|
+
if (stat.isDirectory()) entries = walkDirectory(absolute);
|
|
314
|
+
else if (stat.isFile()) {
|
|
315
|
+
originalBytes = fs.readFileSync(absolute);
|
|
316
|
+
const lower = absolute.toLowerCase();
|
|
317
|
+
if (lower.endsWith(".zip")) {
|
|
318
|
+
inputType = "zip";
|
|
319
|
+
({ entries, blockers } = parseZip(originalBytes, inputLimits));
|
|
320
|
+
} else if (lower.endsWith(".tar")) {
|
|
321
|
+
inputType = "tar";
|
|
322
|
+
({ entries, blockers } = parseTar(originalBytes, inputLimits));
|
|
323
|
+
} else if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
|
|
324
|
+
inputType = "tar.gz";
|
|
325
|
+
let unpacked;
|
|
326
|
+
try { unpacked = zlib.gunzipSync(originalBytes, { maxOutputLength: inputLimits.maxTotalBytes + 1 }); }
|
|
327
|
+
catch (_) { return { blockers: ["artifact_gzip_invalid_or_limit_exceeded"], entries: [] }; }
|
|
328
|
+
if (unpacked.length / Math.max(1, originalBytes.length) > inputLimits.maxCompressionRatio) blockers.push("artifact_compression_ratio_limit_exceeded");
|
|
329
|
+
const parsed = parseTar(unpacked, inputLimits);
|
|
330
|
+
entries = parsed.entries;
|
|
331
|
+
blockers.push(...parsed.blockers);
|
|
332
|
+
} else if (lower.endsWith(".rar") || lower.endsWith(".7z")) {
|
|
333
|
+
return { blockers: ["artifact_archive_provider_required"], entries: [] };
|
|
334
|
+
} else {
|
|
335
|
+
entries = [{ path: path.basename(absolute), bytes: originalBytes }];
|
|
336
|
+
}
|
|
337
|
+
} else return { blockers: ["artifact_input_type_unsupported"], entries: [] };
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return { blockers: [String(error.message || "artifact_input_invalid")], entries: [] };
|
|
340
|
+
}
|
|
341
|
+
const validated = validateEntries(entries, inputLimits);
|
|
342
|
+
blockers.push(...validated.blockers);
|
|
343
|
+
const normalized = entries.map((entry) => ({
|
|
344
|
+
path: safeRelative(entry.path),
|
|
345
|
+
bytes: entry.bytes,
|
|
346
|
+
size: entry.bytes.length,
|
|
347
|
+
sha256: sha256(entry.bytes),
|
|
348
|
+
})).filter((entry) => entry.path).sort((a, b) => a.path.localeCompare(b.path));
|
|
349
|
+
const inventory = normalized.map(({ path: relative, size, sha256: digest }) => ({ path: relative, size, sha256: digest }));
|
|
350
|
+
return {
|
|
351
|
+
blockers: [...new Set(blockers)].sort(),
|
|
352
|
+
entries: normalized,
|
|
353
|
+
input_type: inputType,
|
|
354
|
+
input_name: stat.isFile() ? path.basename(absolute) : null,
|
|
355
|
+
input_sha256: stat.isFile() ? sha256(originalBytes) : null,
|
|
356
|
+
package_digest: sha256(canonicalBytes(inventory), true),
|
|
357
|
+
file_count: normalized.length,
|
|
358
|
+
total_bytes: normalized.reduce((sum, entry) => sum + entry.size, 0),
|
|
359
|
+
inventory,
|
|
360
|
+
absolute,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function inspectArtifactBundle(repository, input, options = {}) {
|
|
365
|
+
const inspected = inspectInput(input, options);
|
|
366
|
+
return result("inspect", "read_only", inspected.blockers.length ? "blocked" : "success", {
|
|
367
|
+
repository_id: path.basename(normalizeRepository(repository)),
|
|
368
|
+
bundle: {
|
|
369
|
+
input_type: inspected.input_type || null,
|
|
370
|
+
file_count: inspected.file_count || 0,
|
|
371
|
+
total_bytes: inspected.total_bytes || 0,
|
|
372
|
+
package_digest: inspected.package_digest || null,
|
|
373
|
+
files: inspected.inventory || [],
|
|
374
|
+
},
|
|
375
|
+
blockers: inspected.blockers,
|
|
376
|
+
next_action: inspected.blockers.length ? "resolve the listed safe-input blockers" : "create a release preview or apply it",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function registryPath(repository) {
|
|
381
|
+
return path.join(repository, REGISTRY_FILE);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function emptyRegistry(repository) {
|
|
385
|
+
return {
|
|
386
|
+
schema_version: "1.0.0",
|
|
387
|
+
operation_id: "mirai.project_technology.artifact_registry",
|
|
388
|
+
repository_id: path.basename(repository),
|
|
389
|
+
matters: [],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function readRegistry(repository) {
|
|
394
|
+
const file = registryPath(repository);
|
|
395
|
+
if (!fs.existsSync(file)) return { registry: emptyRegistry(repository), bytes: null, blockers: [] };
|
|
396
|
+
if (fs.lstatSync(file).isSymbolicLink()) return { registry: null, bytes: null, blockers: ["artifact_registry_symlink_unsafe"] };
|
|
397
|
+
try {
|
|
398
|
+
const bytes = fs.readFileSync(file);
|
|
399
|
+
const registry = JSON.parse(bytes.toString("utf8").replace(/^\uFEFF/, ""));
|
|
400
|
+
if (!registry || registry.schema_version !== "1.0.0" || !Array.isArray(registry.matters)) throw new Error("invalid");
|
|
401
|
+
return { registry, bytes, blockers: [] };
|
|
402
|
+
} catch (_) {
|
|
403
|
+
return { registry: null, bytes: null, blockers: ["artifact_registry_invalid"] };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function registryDigest(registry) {
|
|
408
|
+
return sha256(canonicalBytes(registry), true);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeArtifactRoot(repository, requested) {
|
|
412
|
+
const relative = safeRelative(requested || DEFAULT_ROOT);
|
|
413
|
+
if (!relative) return null;
|
|
414
|
+
const absolute = path.resolve(repository, relative);
|
|
415
|
+
return absolute.startsWith(`${repository}${path.sep}`) ? { relative, absolute } : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function datePrefix(value) {
|
|
419
|
+
const date = value ? String(value).replace(/-/g, "") : new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
420
|
+
return /^\d{8}$/.test(date) ? date : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function nextReleaseId(releases, prefix) {
|
|
424
|
+
const sequence = releases.map((item) => String(item.release_id || ""))
|
|
425
|
+
.filter((id) => id.startsWith(`${prefix}-`) && RELEASE_ID_RE.test(id))
|
|
426
|
+
.map((id) => Number(id.slice(-2))).reduce((max, value) => Math.max(max, value), 0) + 1;
|
|
427
|
+
return sequence <= 99 ? `${prefix}-${String(sequence).padStart(2, "0")}` : null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function acquireLease(file) {
|
|
431
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
432
|
+
try {
|
|
433
|
+
const descriptor = fs.openSync(file, "wx", 0o600);
|
|
434
|
+
fs.writeFileSync(descriptor, canonicalBytes({ pid: process.pid, operation: "artifact_release" }));
|
|
435
|
+
return descriptor;
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error.code === "EEXIST") return null;
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function releaseLease(file, descriptor) {
|
|
443
|
+
try { if (descriptor !== null) fs.closeSync(descriptor); } catch (_) { /* already closed */ }
|
|
444
|
+
try { fs.unlinkSync(file); } catch (_) { /* best effort */ }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function writeEntries(root, entries) {
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const destination = path.join(root, ...entry.path.split("/"));
|
|
450
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
451
|
+
fs.writeFileSync(destination, entry.bytes, { mode: 0o600 });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function findRelease(registry, matterId, releaseId) {
|
|
456
|
+
const matter = registry.matters.find((item) => item.matter_id === matterId);
|
|
457
|
+
return matter?.releases?.find((item) => item.release_id === releaseId) || null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function manifestAt(repository, release) {
|
|
461
|
+
const ref = safeRelative(release?.manifest_ref || "");
|
|
462
|
+
if (!ref) return null;
|
|
463
|
+
const absolute = path.resolve(repository, ref);
|
|
464
|
+
if (!absolute.startsWith(`${repository}${path.sep}`) || !fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink()) return null;
|
|
465
|
+
try { return JSON.parse(fs.readFileSync(absolute, "utf8")); } catch (_) { return null; }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function compareInventories(baseManifest, targetManifest) {
|
|
469
|
+
const base = new Map((baseManifest?.files || []).map((entry) => [entry.path, entry]));
|
|
470
|
+
const target = new Map((targetManifest?.files || []).map((entry) => [entry.path, entry]));
|
|
471
|
+
const added = [];
|
|
472
|
+
const removed = [];
|
|
473
|
+
const changed = [];
|
|
474
|
+
const unchanged = [];
|
|
475
|
+
for (const relative of [...new Set([...base.keys(), ...target.keys()])].sort()) {
|
|
476
|
+
if (!base.has(relative)) added.push(relative);
|
|
477
|
+
else if (!target.has(relative)) removed.push(relative);
|
|
478
|
+
else if (base.get(relative).sha256 !== target.get(relative).sha256) changed.push(relative);
|
|
479
|
+
else unchanged.push(relative);
|
|
480
|
+
}
|
|
481
|
+
return { added, removed, changed, unchanged };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function compareArtifactReleases(repository, baseReleaseId, targetReleaseId, options = {}) {
|
|
485
|
+
const repo = normalizeRepository(repository);
|
|
486
|
+
const loaded = readRegistry(repo);
|
|
487
|
+
if (loaded.blockers.length) return result("compare", "read_only", "blocked", { blockers: loaded.blockers });
|
|
488
|
+
const matterId = safeMatterId(options.matterId);
|
|
489
|
+
if (!matterId) return result("compare", "read_only", "blocked", { blockers: ["artifact_matter_id_invalid"] });
|
|
490
|
+
const base = findRelease(loaded.registry, matterId, baseReleaseId);
|
|
491
|
+
const target = findRelease(loaded.registry, matterId, targetReleaseId);
|
|
492
|
+
const blockers = [];
|
|
493
|
+
if (!base) blockers.push("artifact_base_release_missing");
|
|
494
|
+
if (!target) blockers.push("artifact_target_release_missing");
|
|
495
|
+
const baseManifest = base ? manifestAt(repo, base) : null;
|
|
496
|
+
const targetManifest = target ? manifestAt(repo, target) : null;
|
|
497
|
+
if (base && !baseManifest) blockers.push("artifact_base_manifest_missing_or_invalid");
|
|
498
|
+
if (target && !targetManifest) blockers.push("artifact_target_manifest_missing_or_invalid");
|
|
499
|
+
return result("compare", "read_only", blockers.length ? "blocked" : "success", {
|
|
500
|
+
matter_id: matterId,
|
|
501
|
+
base_release_id: baseReleaseId,
|
|
502
|
+
target_release_id: targetReleaseId,
|
|
503
|
+
comparison: blockers.length ? null : compareInventories(baseManifest, targetManifest),
|
|
504
|
+
blockers,
|
|
505
|
+
next_action: blockers.length ? "repair or select existing immutable releases" : "use domain review to interpret the technical changes",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function createArtifactRelease(repository, input, options = {}) {
|
|
510
|
+
const repo = normalizeRepository(repository);
|
|
511
|
+
const matterId = safeMatterId(options.matterId);
|
|
512
|
+
const direction = SAFE_DIRECTIONS.has(options.direction) ? options.direction : null;
|
|
513
|
+
const root = normalizeArtifactRoot(repo, options.artifactRoot);
|
|
514
|
+
const blockers = [];
|
|
515
|
+
if (!matterId) blockers.push("artifact_matter_id_invalid");
|
|
516
|
+
if (!direction) blockers.push("artifact_direction_invalid");
|
|
517
|
+
if (!root) blockers.push("artifact_root_unsafe");
|
|
518
|
+
const inspected = inspectInput(input, options);
|
|
519
|
+
blockers.push(...inspected.blockers);
|
|
520
|
+
if (blockers.length) return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: [...new Set(blockers)].sort(), next_action: "resolve the listed release blockers" });
|
|
521
|
+
const loaded = readRegistry(repo);
|
|
522
|
+
if (loaded.blockers.length) return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: loaded.blockers });
|
|
523
|
+
const currentDigest = registryDigest(loaded.registry);
|
|
524
|
+
if (options.expectedGraphDigest && options.expectedGraphDigest !== currentDigest) {
|
|
525
|
+
return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: ["artifact_registry_compare_and_swap_conflict"], current_graph_digest: currentDigest });
|
|
526
|
+
}
|
|
527
|
+
const matter = loaded.registry.matters.find((item) => item.matter_id === matterId);
|
|
528
|
+
const releases = matter?.releases || [];
|
|
529
|
+
const prefix = datePrefix(options.releaseDate);
|
|
530
|
+
if (!prefix) return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: ["artifact_release_date_invalid"] });
|
|
531
|
+
const requestedId = options.releaseId || nextReleaseId(releases, prefix);
|
|
532
|
+
if (!requestedId || !RELEASE_ID_RE.test(requestedId)) return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: ["artifact_release_id_invalid_or_exhausted"] });
|
|
533
|
+
const parents = [...new Set(options.parentReleaseIds || [])].sort();
|
|
534
|
+
for (const parentId of parents) if (!findRelease(loaded.registry, matterId, parentId)) blockers.push("artifact_parent_release_missing");
|
|
535
|
+
const duplicate = releases.find((item) => item.package_digest === inspected.package_digest && item.direction === direction && canonicalBytes(item.parent_release_ids || []) === canonicalBytes(parents));
|
|
536
|
+
if (duplicate) return result("release", options.apply ? "transactional" : "preview", "success", {
|
|
537
|
+
changed: false,
|
|
538
|
+
matter_id: matterId,
|
|
539
|
+
release_id: duplicate.release_id,
|
|
540
|
+
package_digest: duplicate.package_digest,
|
|
541
|
+
graph_digest: currentDigest,
|
|
542
|
+
next_action: "none",
|
|
543
|
+
});
|
|
544
|
+
const existing = findRelease(loaded.registry, matterId, requestedId);
|
|
545
|
+
if (existing) blockers.push("artifact_release_id_conflict");
|
|
546
|
+
if (blockers.length) return result("release", options.apply ? "transactional" : "preview", "blocked", { blockers: [...new Set(blockers)].sort() });
|
|
547
|
+
const planned = {
|
|
548
|
+
matter_id: matterId,
|
|
549
|
+
release_id: requestedId,
|
|
550
|
+
direction,
|
|
551
|
+
parent_release_ids: parents,
|
|
552
|
+
file_count: inspected.file_count,
|
|
553
|
+
total_bytes: inspected.total_bytes,
|
|
554
|
+
package_digest: inspected.package_digest,
|
|
555
|
+
artifact_root: root.relative,
|
|
556
|
+
};
|
|
557
|
+
if (!options.apply) return result("release", "preview", "preview", { planned_release: planned, apply_required: true, graph_digest: currentDigest, next_action: "rerun with --apply" });
|
|
558
|
+
|
|
559
|
+
const matterRoot = path.join(root.absolute, matterId);
|
|
560
|
+
const releasesRoot = path.join(matterRoot, "releases");
|
|
561
|
+
const finalRoot = path.join(releasesRoot, requestedId);
|
|
562
|
+
const leaseFile = path.join(matterRoot, ".artifact-release.lock");
|
|
563
|
+
const lease = acquireLease(leaseFile);
|
|
564
|
+
if (lease === null) return result("release", "transactional", "blocked", { blockers: ["artifact_release_lease_conflict"], next_action: "retry after the current release operation finishes" });
|
|
565
|
+
let staging = null;
|
|
566
|
+
try {
|
|
567
|
+
const reloaded = readRegistry(repo);
|
|
568
|
+
if (reloaded.blockers.length) throw new Error(reloaded.blockers[0]);
|
|
569
|
+
if (registryDigest(reloaded.registry) !== currentDigest) throw new Error("artifact_registry_compare_and_swap_conflict");
|
|
570
|
+
if (fs.existsSync(finalRoot)) throw new Error("artifact_release_id_conflict");
|
|
571
|
+
fs.mkdirSync(releasesRoot, { recursive: true });
|
|
572
|
+
staging = fs.mkdtempSync(path.join(releasesRoot, `.${requestedId}.tmp-`));
|
|
573
|
+
writeEntries(path.join(staging, "package"), inspected.entries);
|
|
574
|
+
fs.mkdirSync(path.join(staging, "original"), { recursive: true });
|
|
575
|
+
const sourceStat = fs.lstatSync(inspected.absolute);
|
|
576
|
+
if (sourceStat.isFile()) fs.copyFileSync(inspected.absolute, path.join(staging, "original", path.basename(inspected.absolute)));
|
|
577
|
+
else writeEntries(path.join(staging, "original"), inspected.entries);
|
|
578
|
+
fs.mkdirSync(path.join(staging, "changes"), { recursive: true });
|
|
579
|
+
fs.mkdirSync(path.join(staging, "internal"), { recursive: true });
|
|
580
|
+
const parentManifest = parents.length === 1 ? manifestAt(repo, findRelease(reloaded.registry, matterId, parents[0])) : null;
|
|
581
|
+
const manifest = {
|
|
582
|
+
schema_version: "1.0.0",
|
|
583
|
+
operation_id: "mirai.project_technology.artifact_release_manifest",
|
|
584
|
+
matter_id: matterId,
|
|
585
|
+
release_id: requestedId,
|
|
586
|
+
direction,
|
|
587
|
+
parent_release_ids: parents,
|
|
588
|
+
source: {
|
|
589
|
+
kind: inspected.input_type,
|
|
590
|
+
original_name: inspected.input_name,
|
|
591
|
+
original_sha256: inspected.input_sha256,
|
|
592
|
+
},
|
|
593
|
+
files: inspected.inventory,
|
|
594
|
+
file_count: inspected.file_count,
|
|
595
|
+
total_bytes: inspected.total_bytes,
|
|
596
|
+
package_digest: inspected.package_digest,
|
|
597
|
+
};
|
|
598
|
+
fs.writeFileSync(path.join(staging, "release-manifest.json"), canonicalBytes(manifest), { mode: 0o600 });
|
|
599
|
+
const technical = {
|
|
600
|
+
schema_version: "1.0.0",
|
|
601
|
+
base_release_id: parents.length === 1 ? parents[0] : null,
|
|
602
|
+
target_release_id: requestedId,
|
|
603
|
+
comparison: compareInventories(parentManifest, manifest),
|
|
604
|
+
};
|
|
605
|
+
fs.writeFileSync(path.join(staging, "changes", "technical.json"), canonicalBytes(technical), { mode: 0o600 });
|
|
606
|
+
if (typeof options.clientNote === "string" && options.clientNote.trim()) fs.writeFileSync(path.join(staging, "changes", "client.md"), `${options.clientNote.trim()}\n`, { mode: 0o600 });
|
|
607
|
+
const manifestBytes = fs.readFileSync(path.join(staging, "release-manifest.json"));
|
|
608
|
+
fs.renameSync(staging, finalRoot);
|
|
609
|
+
staging = null;
|
|
610
|
+
const releaseRef = path.relative(repo, finalRoot).split(path.sep).join("/");
|
|
611
|
+
const record = {
|
|
612
|
+
release_id: requestedId,
|
|
613
|
+
direction,
|
|
614
|
+
parent_release_ids: parents,
|
|
615
|
+
storage_ref: releaseRef,
|
|
616
|
+
manifest_ref: `${releaseRef}/release-manifest.json`,
|
|
617
|
+
manifest_sha256: sha256(manifestBytes, true),
|
|
618
|
+
package_digest: inspected.package_digest,
|
|
619
|
+
state: options.state || "recorded",
|
|
620
|
+
};
|
|
621
|
+
const registry = structuredClone(reloaded.registry);
|
|
622
|
+
let targetMatter = registry.matters.find((item) => item.matter_id === matterId);
|
|
623
|
+
if (!targetMatter) {
|
|
624
|
+
targetMatter = { matter_id: matterId, releases: [] };
|
|
625
|
+
registry.matters.push(targetMatter);
|
|
626
|
+
}
|
|
627
|
+
targetMatter.releases.push(record);
|
|
628
|
+
targetMatter.releases.sort((a, b) => a.release_id.localeCompare(b.release_id));
|
|
629
|
+
registry.matters.sort((a, b) => a.matter_id.localeCompare(b.matter_id));
|
|
630
|
+
const registryBytes = canonicalBytes(registry);
|
|
631
|
+
fs.mkdirSync(path.dirname(registryPath(repo)), { recursive: true });
|
|
632
|
+
const tempRegistry = `${registryPath(repo)}.${process.pid}.tmp`;
|
|
633
|
+
fs.writeFileSync(tempRegistry, registryBytes);
|
|
634
|
+
fs.renameSync(tempRegistry, registryPath(repo));
|
|
635
|
+
const readback = readRegistry(repo);
|
|
636
|
+
if (readback.blockers.length || !findRelease(readback.registry, matterId, requestedId)) throw new Error("artifact_release_readback_failed");
|
|
637
|
+
let exportRef = null;
|
|
638
|
+
let exportSha256 = null;
|
|
639
|
+
if (options.createExport) {
|
|
640
|
+
const exportRoot = path.join(matterRoot, "exports");
|
|
641
|
+
fs.mkdirSync(exportRoot, { recursive: true });
|
|
642
|
+
const exportFiles = {};
|
|
643
|
+
for (const entry of inspected.entries) exportFiles[`package/${entry.path}`] = new Uint8Array(entry.bytes);
|
|
644
|
+
const notePath = path.join(finalRoot, "changes", "client.md");
|
|
645
|
+
if (fs.existsSync(notePath)) exportFiles["changes/client.md"] = new Uint8Array(fs.readFileSync(notePath));
|
|
646
|
+
const archive = createZip(exportFiles);
|
|
647
|
+
const exportPath = path.join(exportRoot, `${requestedId}.zip`);
|
|
648
|
+
fs.writeFileSync(exportPath, archive, { mode: 0o600 });
|
|
649
|
+
exportRef = path.relative(repo, exportPath).split(path.sep).join("/");
|
|
650
|
+
exportSha256 = sha256(archive, true);
|
|
651
|
+
}
|
|
652
|
+
return result("release", "transactional", "success", {
|
|
653
|
+
changed: true,
|
|
654
|
+
...planned,
|
|
655
|
+
manifest_ref: record.manifest_ref,
|
|
656
|
+
manifest_sha256: record.manifest_sha256,
|
|
657
|
+
graph_digest: registryDigest(registry),
|
|
658
|
+
export_ref: exportRef,
|
|
659
|
+
export_sha256: exportSha256,
|
|
660
|
+
terminal_receipt: sha256(canonicalBytes({ ...record, graph_digest: registryDigest(registry) }), true),
|
|
661
|
+
});
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (staging && fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
664
|
+
if (fs.existsSync(finalRoot)) fs.rmSync(finalRoot, { recursive: true, force: true });
|
|
665
|
+
if (loaded.bytes) fs.writeFileSync(registryPath(repo), loaded.bytes);
|
|
666
|
+
else if (fs.existsSync(registryPath(repo))) fs.rmSync(registryPath(repo), { force: true });
|
|
667
|
+
return result("release", "transactional", "fail", {
|
|
668
|
+
blockers: [String(error.message || "artifact_release_transaction_failed")],
|
|
669
|
+
next_action: "inspect the blocker; the previous registry state was restored",
|
|
670
|
+
});
|
|
671
|
+
} finally {
|
|
672
|
+
releaseLease(leaseFile, lease);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function verifyArtifactRelease(repository, releaseId, options = {}) {
|
|
677
|
+
const repo = normalizeRepository(repository);
|
|
678
|
+
const matterId = safeMatterId(options.matterId);
|
|
679
|
+
if (!matterId) return result("verify", "read_only", "blocked", { blockers: ["artifact_matter_id_invalid"] });
|
|
680
|
+
const loaded = readRegistry(repo);
|
|
681
|
+
if (loaded.blockers.length) return result("verify", "read_only", "blocked", { blockers: loaded.blockers });
|
|
682
|
+
const record = findRelease(loaded.registry, matterId, releaseId);
|
|
683
|
+
if (!record) return result("verify", "read_only", "blocked", { blockers: ["artifact_release_missing"] });
|
|
684
|
+
const blockers = [];
|
|
685
|
+
const manifest = manifestAt(repo, record);
|
|
686
|
+
if (!manifest) blockers.push("artifact_manifest_missing_or_invalid");
|
|
687
|
+
else {
|
|
688
|
+
const manifestPath = path.resolve(repo, record.manifest_ref);
|
|
689
|
+
const bytes = fs.readFileSync(manifestPath);
|
|
690
|
+
if (sha256(bytes, true) !== record.manifest_sha256) blockers.push("artifact_manifest_digest_mismatch");
|
|
691
|
+
if (manifest.package_digest !== record.package_digest) blockers.push("artifact_package_digest_mismatch");
|
|
692
|
+
const releaseRoot = path.dirname(manifestPath);
|
|
693
|
+
const entries = [];
|
|
694
|
+
for (const file of manifest.files || []) {
|
|
695
|
+
const relative = safeRelative(file.path);
|
|
696
|
+
const absolute = relative ? path.join(releaseRoot, "package", ...relative.split("/")) : null;
|
|
697
|
+
if (!absolute || !absolute.startsWith(`${path.join(releaseRoot, "package")}${path.sep}`) || !fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink()) {
|
|
698
|
+
blockers.push("artifact_package_file_missing_or_unsafe");
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const content = fs.readFileSync(absolute);
|
|
702
|
+
if (sha256(content) !== file.sha256 || content.length !== file.size) blockers.push("artifact_package_file_digest_mismatch");
|
|
703
|
+
entries.push({ path: relative, size: content.length, sha256: sha256(content) });
|
|
704
|
+
}
|
|
705
|
+
if (sha256(canonicalBytes(entries.sort((a, b) => a.path.localeCompare(b.path))), true) !== record.package_digest) blockers.push("artifact_package_digest_mismatch");
|
|
706
|
+
}
|
|
707
|
+
for (const parent of record.parent_release_ids || []) if (!findRelease(loaded.registry, matterId, parent)) blockers.push("artifact_parent_release_missing");
|
|
708
|
+
return result("verify", "read_only", blockers.length ? "blocked" : "success", {
|
|
709
|
+
matter_id: matterId,
|
|
710
|
+
release_id: releaseId,
|
|
711
|
+
package_digest: record.package_digest,
|
|
712
|
+
manifest_ref: record.manifest_ref,
|
|
713
|
+
graph_digest: registryDigest(loaded.registry),
|
|
714
|
+
blockers: [...new Set(blockers)].sort(),
|
|
715
|
+
next_action: blockers.length ? "restore the immutable release from a verified backup" : "none",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function executeArtifact(repository, options = {}) {
|
|
720
|
+
const action = options.artifactAction;
|
|
721
|
+
if (action === "inspect") return inspectArtifactBundle(repository, options.input, options);
|
|
722
|
+
if (action === "release") return createArtifactRelease(repository, options.input, options);
|
|
723
|
+
if (action === "compare") return compareArtifactReleases(repository, options.baseReleaseId, options.targetReleaseId, options);
|
|
724
|
+
if (action === "verify") return verifyArtifactRelease(repository, options.releaseId, options);
|
|
725
|
+
return result(String(action || "unknown"), "read_only", "fail", { blockers: ["artifact_operation_unsupported"] });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
module.exports = {
|
|
729
|
+
DEFAULT_ROOT,
|
|
730
|
+
REGISTRY_FILE,
|
|
731
|
+
canonicalBytes,
|
|
732
|
+
compareArtifactReleases,
|
|
733
|
+
createArtifactRelease,
|
|
734
|
+
executeArtifact,
|
|
735
|
+
inspectArtifactBundle,
|
|
736
|
+
sha256,
|
|
737
|
+
verifyArtifactRelease,
|
|
738
|
+
};
|