@nanhara/hara 0.127.2 → 0.129.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.
@@ -0,0 +1,792 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, realpathSync, renameSync, } from "node:fs";
3
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
5
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
6
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
7
+ import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, PrivateStateConflictError, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
8
+ export const ARTIFACT_PROTOCOL_VERSION = "artifact/1";
9
+ export const MAX_ARTIFACT_IMPORT_BYTES = 64 * 1024 * 1024;
10
+ const MAX_ARTIFACTS = 10_000;
11
+ const MAX_REVISIONS = 10_000;
12
+ const JSON_LIMIT = 512 * 1024;
13
+ const ARTIFACT_ID = /^art_[a-f0-9]{32}$/;
14
+ const REVISION_ID = /^rev_[a-f0-9]{32}$/;
15
+ const SAFE_CONTENT_REF = /^content\.[a-z0-9]{1,12}$/;
16
+ export class ArtifactStoreError extends Error {
17
+ code;
18
+ constructor(code, message, options) {
19
+ super(message, options);
20
+ this.code = code;
21
+ this.name = "ArtifactStoreError";
22
+ }
23
+ }
24
+ const FORMATS = new Map([
25
+ [".pptx", { kind: "presentation", mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }],
26
+ [".ppt", { kind: "presentation", mediaType: "application/vnd.ms-powerpoint" }],
27
+ [".odp", { kind: "presentation", mediaType: "application/vnd.oasis.opendocument.presentation" }],
28
+ [".xlsx", { kind: "spreadsheet", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }],
29
+ [".xls", { kind: "spreadsheet", mediaType: "application/vnd.ms-excel" }],
30
+ [".csv", { kind: "spreadsheet", mediaType: "text/csv" }],
31
+ [".ods", { kind: "spreadsheet", mediaType: "application/vnd.oasis.opendocument.spreadsheet" }],
32
+ [".docx", { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }],
33
+ [".doc", { kind: "document", mediaType: "application/msword" }],
34
+ [".md", { kind: "document", mediaType: "text/markdown" }],
35
+ [".txt", { kind: "document", mediaType: "text/plain" }],
36
+ [".rtf", { kind: "document", mediaType: "application/rtf" }],
37
+ [".odt", { kind: "document", mediaType: "application/vnd.oasis.opendocument.text" }],
38
+ ]);
39
+ const MACRO_FORMATS = new Set([".pptm", ".ppsm", ".xlsm", ".xltm", ".docm", ".dotm"]);
40
+ const ZIP_FORMATS = new Set([".pptx", ".odp", ".xlsx", ".ods", ".docx", ".odt"]);
41
+ const COMPOUND_FORMATS = new Set([".ppt", ".xls", ".doc"]);
42
+ const newOpaqueId = (prefix) => `${prefix}_${randomUUID().replaceAll("-", "")}`;
43
+ const storeError = (code, message, cause) => new ArtifactStoreError(code, message, cause === undefined ? undefined : { cause });
44
+ function verifyDirectory(identity) {
45
+ const info = lstatSync(identity.path);
46
+ if (!info.isDirectory()
47
+ || info.isSymbolicLink()
48
+ || info.dev !== identity.dev
49
+ || info.ino !== identity.ino
50
+ || realpathSync.native(identity.path) !== identity.path)
51
+ throw storeError("ARTIFACT_CORRUPT", "private artifact directory identity changed");
52
+ }
53
+ function artifactRoot(home) {
54
+ return ensurePrivateStateSubdirectory(home, [".hara", "artifacts"]);
55
+ }
56
+ function parseJsonFile(path) {
57
+ const snapshot = readPrivateStateFileSnapshotSync(path, JSON_LIMIT);
58
+ if (!snapshot)
59
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
60
+ try {
61
+ return JSON.parse(snapshot.text);
62
+ }
63
+ catch (error) {
64
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
65
+ }
66
+ }
67
+ function plainRecord(value) {
68
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
69
+ }
70
+ function exactKeys(value, required, optional) {
71
+ const allowed = new Set([...required, ...optional]);
72
+ return required.every((key) => Object.hasOwn(value, key))
73
+ && Object.keys(value).every((key) => allowed.has(key));
74
+ }
75
+ function isArtifactLockRef(value) {
76
+ if (!plainRecord(value) || !exactKeys(value, ["id", "version", "sha256"], []))
77
+ return false;
78
+ return typeof value.id === "string"
79
+ && value.id.length >= 1
80
+ && value.id.length <= 256
81
+ && typeof value.version === "string"
82
+ && value.version.length >= 1
83
+ && value.version.length <= 128
84
+ && typeof value.sha256 === "string"
85
+ && /^[a-f0-9]{64}$/.test(value.sha256);
86
+ }
87
+ function isArtifactRecord(value) {
88
+ if (!plainRecord(value) || !exactKeys(value, ["protocol", "artifactId", "kind", "title", "currentRevisionId"], ["origin", "dataResidency", "capabilityLock", "templateLock"]))
89
+ return false;
90
+ return value.protocol === ARTIFACT_PROTOCOL_VERSION
91
+ && typeof value.artifactId === "string"
92
+ && ARTIFACT_ID.test(value.artifactId)
93
+ && (value.kind === "presentation" || value.kind === "spreadsheet" || value.kind === "document")
94
+ && typeof value.title === "string"
95
+ && value.title.length >= 1
96
+ && value.title.length <= 1_024
97
+ && typeof value.currentRevisionId === "string"
98
+ && REVISION_ID.test(value.currentRevisionId)
99
+ && (value.origin === undefined || (typeof value.origin === "string" && value.origin.length <= 256))
100
+ && (value.dataResidency === undefined
101
+ || value.dataResidency === "local"
102
+ || value.dataResidency === "cn"
103
+ || value.dataResidency === "global")
104
+ && (value.capabilityLock === undefined || isArtifactLockRef(value.capabilityLock))
105
+ && (value.templateLock === undefined || isArtifactLockRef(value.templateLock));
106
+ }
107
+ function isRevision(value) {
108
+ if (!plainRecord(value) || !exactKeys(value, [
109
+ "revisionId",
110
+ "artifactId",
111
+ "baseRevisionId",
112
+ "actor",
113
+ "contentRef",
114
+ "assetRefs",
115
+ "contentDigest",
116
+ "changedPaths",
117
+ "createdAt",
118
+ ], ["parentRevisionId", "taskRunId"]))
119
+ return false;
120
+ return typeof value.revisionId === "string"
121
+ && REVISION_ID.test(value.revisionId)
122
+ && typeof value.artifactId === "string"
123
+ && ARTIFACT_ID.test(value.artifactId)
124
+ && typeof value.baseRevisionId === "string"
125
+ && REVISION_ID.test(value.baseRevisionId)
126
+ && (value.parentRevisionId === undefined
127
+ || (typeof value.parentRevisionId === "string" && REVISION_ID.test(value.parentRevisionId)))
128
+ && (value.actor === "user" || value.actor === "agent" || value.actor === "migration")
129
+ && (value.taskRunId === undefined || (typeof value.taskRunId === "string" && value.taskRunId.length > 0))
130
+ && typeof value.contentRef === "string"
131
+ && SAFE_CONTENT_REF.test(value.contentRef)
132
+ && Array.isArray(value.assetRefs)
133
+ && value.assetRefs.length <= 10_000
134
+ && value.assetRefs.every((entry) => typeof entry === "string" && entry.length > 0)
135
+ && typeof value.contentDigest === "string"
136
+ && /^[a-f0-9]{64}$/.test(value.contentDigest)
137
+ && Array.isArray(value.changedPaths)
138
+ && value.changedPaths.length <= 10_000
139
+ && value.changedPaths.every((entry) => typeof entry === "string" && entry.length > 0)
140
+ && typeof value.createdAt === "string"
141
+ && Number.isFinite(Date.parse(value.createdAt));
142
+ }
143
+ function isContentInfo(value) {
144
+ if (!plainRecord(value) || !exactKeys(value, ["contentRef", "extension", "mediaType", "byteSize", "sha256"], []))
145
+ return false;
146
+ return typeof value.contentRef === "string"
147
+ && SAFE_CONTENT_REF.test(value.contentRef)
148
+ && typeof value.extension === "string"
149
+ && /^\.[a-z0-9]{1,12}$/.test(value.extension)
150
+ && value.contentRef === `content${value.extension}`
151
+ && typeof value.mediaType === "string"
152
+ && value.mediaType.length > 0
153
+ && typeof value.byteSize === "number"
154
+ && Number.isSafeInteger(value.byteSize)
155
+ && value.byteSize >= 0
156
+ && value.byteSize <= MAX_ARTIFACT_IMPORT_BYTES
157
+ && typeof value.sha256 === "string"
158
+ && /^[a-f0-9]{64}$/.test(value.sha256);
159
+ }
160
+ function checkedArtifactId(value) {
161
+ if (!ARTIFACT_ID.test(value)) {
162
+ throw storeError("ARTIFACT_INVALID_INPUT", "artifactId is not a valid opaque Artifact id");
163
+ }
164
+ return value;
165
+ }
166
+ function checkedRevisionId(value) {
167
+ if (!REVISION_ID.test(value)) {
168
+ throw storeError("ARTIFACT_CORRUPT", "revision id is invalid");
169
+ }
170
+ return value;
171
+ }
172
+ function checkedInputRevisionId(value, field) {
173
+ if (typeof value !== "string" || !REVISION_ID.test(value)) {
174
+ throw storeError("ARTIFACT_INVALID_INPUT", `${field} is not a valid opaque Revision id`);
175
+ }
176
+ return value;
177
+ }
178
+ function checkedActor(value) {
179
+ if (value === undefined)
180
+ return "user";
181
+ if (value !== "user" && value !== "agent" && value !== "migration") {
182
+ throw storeError("ARTIFACT_INVALID_INPUT", "actor must be user, agent, or migration");
183
+ }
184
+ return value;
185
+ }
186
+ function checkedTaskRunId(value) {
187
+ if (value === undefined)
188
+ return undefined;
189
+ if (typeof value !== "string"
190
+ || value.length < 1
191
+ || value.length > 256
192
+ || /[\u0000-\u001f\u007f]/.test(value))
193
+ throw storeError("ARTIFACT_INVALID_INPUT", "taskRunId must be a safe string of at most 256 characters");
194
+ return value;
195
+ }
196
+ function normalizeArtifactPath(value) {
197
+ if (typeof value !== "string" || value.length < 1 || value.length > 1_024 || value.includes("\0")) {
198
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must be non-empty relative paths");
199
+ }
200
+ if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
201
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must use relative forward-slash paths");
202
+ }
203
+ const segments = value.split("/");
204
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
205
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths contains an unsafe path segment");
206
+ }
207
+ return segments.join("/");
208
+ }
209
+ function checkedChangedPaths(value) {
210
+ if (value === undefined)
211
+ return ["content"];
212
+ if (!Array.isArray(value) || value.length < 1 || value.length > 10_000) {
213
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths must contain 1 to 10000 relative paths");
214
+ }
215
+ return [...new Set(value.map(normalizeArtifactPath))];
216
+ }
217
+ function childDirectory(parent, name, notFoundMessage) {
218
+ verifyDirectory(parent);
219
+ const path = join(parent.path, name);
220
+ let info;
221
+ try {
222
+ info = lstatSync(path);
223
+ }
224
+ catch (error) {
225
+ if (error?.code === "ENOENT")
226
+ throw storeError("ARTIFACT_NOT_FOUND", notFoundMessage);
227
+ throw error;
228
+ }
229
+ if (!info.isDirectory()
230
+ || info.isSymbolicLink()
231
+ || realpathSync.native(path) !== path)
232
+ throw storeError("ARTIFACT_CORRUPT", "artifact directory is not a canonical private directory");
233
+ verifyDirectory(parent);
234
+ return { path, dev: info.dev, ino: info.ino };
235
+ }
236
+ function artifactDirectory(home, artifactId) {
237
+ return childDirectory(artifactRoot(home), checkedArtifactId(artifactId), `no artifact ${artifactId}`);
238
+ }
239
+ function readArtifactMetadata(directory) {
240
+ verifyDirectory(directory);
241
+ const snapshot = readPrivateStateFileSnapshotSync(join(directory.path, "metadata.json"), JSON_LIMIT);
242
+ if (!snapshot)
243
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
244
+ let value;
245
+ try {
246
+ value = JSON.parse(snapshot.text);
247
+ }
248
+ catch (error) {
249
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
250
+ }
251
+ if (!isArtifactRecord(value) || value.artifactId !== basename(directory.path)) {
252
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
253
+ }
254
+ verifyDirectory(directory);
255
+ return { artifact: value, text: snapshot.text };
256
+ }
257
+ function revisionDirectory(artifact, revisionId) {
258
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
259
+ return childDirectory(revisions, checkedRevisionId(revisionId), `no revision ${revisionId}`);
260
+ }
261
+ function inspectContent(revision, content, verifyDigest) {
262
+ verifyDirectory(revision);
263
+ const path = join(revision.path, content.contentRef);
264
+ const before = lstatSync(path);
265
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
266
+ throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
267
+ }
268
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
269
+ try {
270
+ const opened = fstatSync(fd);
271
+ if (!opened.isFile()
272
+ || opened.nlink !== 1
273
+ || !sameOpenedFileIdentity(before, opened)
274
+ || opened.size !== content.byteSize
275
+ || (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
276
+ throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
277
+ if (verifyDigest) {
278
+ const hash = createHash("sha256");
279
+ const chunk = Buffer.allocUnsafe(64 * 1024);
280
+ let position = 0;
281
+ for (;;) {
282
+ const read = readSync(fd, chunk, 0, chunk.length, position);
283
+ if (!read)
284
+ break;
285
+ hash.update(chunk.subarray(0, read));
286
+ position += read;
287
+ }
288
+ if (hash.digest("hex") !== content.sha256) {
289
+ throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
290
+ }
291
+ }
292
+ const after = fstatSync(fd);
293
+ const linked = lstatSync(path);
294
+ if (!sameOpenedFileIdentity(opened, after)
295
+ || opened.size !== after.size
296
+ || opened.mtimeMs !== after.mtimeMs
297
+ || opened.ctimeMs !== after.ctimeMs
298
+ || !sameOpenedFileIdentity(after, linked))
299
+ throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was inspected");
300
+ }
301
+ finally {
302
+ closeSync(fd);
303
+ }
304
+ verifyDirectory(revision);
305
+ }
306
+ function readContentBytes(revision, content) {
307
+ verifyDirectory(revision);
308
+ const path = join(revision.path, content.contentRef);
309
+ const before = lstatSync(path);
310
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
311
+ throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
312
+ }
313
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
314
+ try {
315
+ const opened = fstatSync(fd);
316
+ if (!opened.isFile()
317
+ || opened.nlink !== 1
318
+ || !sameOpenedFileIdentity(before, opened)
319
+ || opened.size !== content.byteSize
320
+ || (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
321
+ throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
322
+ const bytes = Buffer.allocUnsafe(content.byteSize);
323
+ let position = 0;
324
+ while (position < bytes.length) {
325
+ const read = readSync(fd, bytes, position, bytes.length - position, position);
326
+ if (!read)
327
+ throw storeError("ARTIFACT_CORRUPT", "artifact content ended before its recorded size");
328
+ position += read;
329
+ }
330
+ if (createHash("sha256").update(bytes).digest("hex") !== content.sha256) {
331
+ throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
332
+ }
333
+ const after = fstatSync(fd);
334
+ const linked = lstatSync(path);
335
+ if (!sameOpenedFileIdentity(opened, after)
336
+ || opened.size !== after.size
337
+ || opened.mtimeMs !== after.mtimeMs
338
+ || opened.ctimeMs !== after.ctimeMs
339
+ || !sameOpenedFileIdentity(after, linked))
340
+ throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was read");
341
+ verifyDirectory(revision);
342
+ return bytes;
343
+ }
344
+ finally {
345
+ closeSync(fd);
346
+ }
347
+ }
348
+ function readRevisionDetails(artifact, revisionId, verifyDigest) {
349
+ const revisionDir = revisionDirectory(artifact, revisionId);
350
+ const revisionValue = parseJsonFile(join(revisionDir.path, "revision.json"));
351
+ const contentValue = parseJsonFile(join(revisionDir.path, "content.json"));
352
+ if (!isRevision(revisionValue)
353
+ || revisionValue.revisionId !== revisionId
354
+ || revisionValue.artifactId !== basename(artifact.path))
355
+ throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its Artifact");
356
+ if (!isContentInfo(contentValue)
357
+ || contentValue.contentRef !== revisionValue.contentRef
358
+ || contentValue.sha256 !== revisionValue.contentDigest)
359
+ throw storeError("ARTIFACT_CORRUPT", "revision content receipt is inconsistent");
360
+ inspectContent(revisionDir, contentValue, verifyDigest);
361
+ return { revision: revisionValue, content: contentValue };
362
+ }
363
+ function readArtifactDetailsFromDirectory(directory, verifyDigest) {
364
+ const { artifact: artifactValue } = readArtifactMetadata(directory);
365
+ const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
366
+ const { revision: revisionValue, content: contentValue } = readRevisionDetails(directory, currentRevisionId, verifyDigest);
367
+ verifyDirectory(directory);
368
+ return { artifact: artifactValue, currentRevision: revisionValue, content: contentValue };
369
+ }
370
+ function titleFor(sourcePath, extension, requested, kind) {
371
+ const raw = requested ?? basename(sourcePath, extension);
372
+ const cleaned = raw
373
+ .normalize("NFC")
374
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
375
+ .replace(/\s+/g, " ")
376
+ .trim();
377
+ const title = cleaned || `Untitled ${kind}`;
378
+ if (title.length > 1_024) {
379
+ throw storeError("ARTIFACT_INVALID_INPUT", "artifact title must be at most 1024 characters");
380
+ }
381
+ return title;
382
+ }
383
+ function sourceFormat(sourcePathInput, claimedKind) {
384
+ if (typeof sourcePathInput !== "string"
385
+ || !sourcePathInput
386
+ || sourcePathInput.length > 4_096) {
387
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
388
+ }
389
+ if (!isAbsolute(sourcePathInput)) {
390
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
391
+ }
392
+ const sourcePath = resolve(sourcePathInput);
393
+ const extension = extname(sourcePath).toLowerCase();
394
+ if (MACRO_FORMATS.has(extension)) {
395
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before using it as an Artifact revision");
396
+ }
397
+ const format = FORMATS.get(extension);
398
+ if (!format) {
399
+ throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
400
+ }
401
+ if (claimedKind !== undefined && claimedKind !== format.kind) {
402
+ throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${claimedKind}`);
403
+ }
404
+ return { sourcePath, extension, format };
405
+ }
406
+ async function readImportSource(sourcePath) {
407
+ let verified;
408
+ try {
409
+ verified = await openVerifiedRegularFileNoFollow(sourcePath, {
410
+ action: "import as an Artifact",
411
+ rejectHardLinks: true,
412
+ protectSensitive: true,
413
+ });
414
+ }
415
+ catch (error) {
416
+ if (error?.code === "ENOENT") {
417
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source file no longer exists", error);
418
+ }
419
+ if (error?.code === "HARA_PROTECTED_CONTEXT_FILE") {
420
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "protected credential/configuration files cannot be imported", error);
421
+ }
422
+ if (error?.code === "HARA_HARD_LINKED_FILE") {
423
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "hard-linked source files cannot be imported safely", error);
424
+ }
425
+ if (error?.code === "HARA_NOT_REGULAR_FILE" || error?.code === "ELOOP") {
426
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source must be a regular file, not a link or device", error);
427
+ }
428
+ throw error;
429
+ }
430
+ try {
431
+ const { handle, info } = verified;
432
+ if (info.size > MAX_ARTIFACT_IMPORT_BYTES) {
433
+ throw storeError("ARTIFACT_TOO_LARGE", `the selected file exceeds the ${MAX_ARTIFACT_IMPORT_BYTES / (1024 * 1024)} MiB import limit`);
434
+ }
435
+ if (info.size === 0) {
436
+ throw storeError("ARTIFACT_INVALID_INPUT", "an empty file cannot be imported as an Artifact");
437
+ }
438
+ const bytes = Buffer.allocUnsafe(info.size);
439
+ let position = 0;
440
+ while (position < bytes.length) {
441
+ const { bytesRead } = await handle.read(bytes, position, bytes.length - position, position);
442
+ if (!bytesRead) {
443
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
444
+ }
445
+ position += bytesRead;
446
+ }
447
+ const after = await handle.stat();
448
+ verifyOpenedRegularFileSync(sourcePath, after, {
449
+ action: "import as an Artifact",
450
+ rejectHardLinks: true,
451
+ protectSensitive: true,
452
+ });
453
+ if (after.dev !== info.dev
454
+ || after.ino !== info.ino
455
+ || after.size !== info.size
456
+ || after.mtimeMs !== info.mtimeMs
457
+ || after.ctimeMs !== info.ctimeMs)
458
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
459
+ return bytes;
460
+ }
461
+ finally {
462
+ await verified.handle.close().catch(() => { });
463
+ }
464
+ }
465
+ function verifyClaimedFormat(extension, bytes) {
466
+ const startsWith = (signature) => bytes.length >= signature.length
467
+ && signature.every((value, index) => bytes[index] === value);
468
+ if (ZIP_FORMATS.has(extension) && !startsWith([0x50, 0x4b, 0x03, 0x04])) {
469
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected Office file does not match its extension; save an unencrypted, macro-free copy and try again");
470
+ }
471
+ if (COMPOUND_FORMATS.has(extension)
472
+ && !startsWith([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])) {
473
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected legacy Office file does not match its extension");
474
+ }
475
+ if (extension === ".rtf"
476
+ && !bytes.subarray(0, Math.min(bytes.length, 32)).toString("utf8").replace(/^\uFEFF/, "").startsWith("{\\rtf")) {
477
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected RTF file does not match its extension");
478
+ }
479
+ }
480
+ function syncDirectory(path) {
481
+ let fd;
482
+ try {
483
+ fd = openSync(path, constants.O_RDONLY
484
+ | optionalPosixOpenFlag("O_NONBLOCK")
485
+ | optionalPosixOpenFlag("O_NOFOLLOW")
486
+ | optionalPosixOpenFlag("O_DIRECTORY"));
487
+ fsyncSync(fd);
488
+ }
489
+ catch {
490
+ /* Directory fsync is not portable; every staged file is still fsync'd before activation. */
491
+ }
492
+ finally {
493
+ if (fd !== undefined)
494
+ closeSync(fd);
495
+ }
496
+ }
497
+ function activateStaging(root, staging, artifactId) {
498
+ verifyDirectory(root);
499
+ verifyDirectory(staging);
500
+ const destination = join(root.path, artifactId);
501
+ try {
502
+ lstatSync(destination);
503
+ throw storeError("ARTIFACT_CORRUPT", "an Artifact id collision occurred; retry the import");
504
+ }
505
+ catch (error) {
506
+ if (error?.code !== "ENOENT")
507
+ throw error;
508
+ }
509
+ renameSync(staging.path, destination);
510
+ const activated = lstatSync(destination);
511
+ if (!activated.isDirectory()
512
+ || activated.isSymbolicLink()
513
+ || activated.dev !== staging.dev
514
+ || activated.ino !== staging.ino
515
+ || realpathSync.native(destination) !== destination)
516
+ throw storeError("ARTIFACT_CORRUPT", "Artifact activation changed the staged directory identity");
517
+ verifyDirectory(root);
518
+ syncDirectory(root.path);
519
+ }
520
+ export async function importArtifact(home, input) {
521
+ if (input.kind !== undefined
522
+ && input.kind !== "presentation"
523
+ && input.kind !== "spreadsheet"
524
+ && input.kind !== "document")
525
+ throw storeError("ARTIFACT_INVALID_INPUT", "kind must be presentation, spreadsheet, or document");
526
+ if (input.title !== undefined && typeof input.title !== "string") {
527
+ throw storeError("ARTIFACT_INVALID_INPUT", "title must be a string");
528
+ }
529
+ const { sourcePath, extension, format } = sourceFormat(input.sourcePath, input.kind);
530
+ const bytes = await readImportSource(sourcePath);
531
+ verifyClaimedFormat(extension, bytes);
532
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
533
+ const artifactId = newOpaqueId("art");
534
+ const revisionId = newOpaqueId("rev");
535
+ const now = new Date().toISOString();
536
+ const contentRef = `content${extension}`;
537
+ const artifact = {
538
+ protocol: ARTIFACT_PROTOCOL_VERSION,
539
+ artifactId,
540
+ kind: format.kind,
541
+ title: titleFor(sourcePath, extension, input.title, format.kind),
542
+ currentRevisionId: revisionId,
543
+ origin: "local-import",
544
+ dataResidency: "local",
545
+ };
546
+ const revision = {
547
+ revisionId,
548
+ artifactId,
549
+ baseRevisionId: revisionId,
550
+ actor: "user",
551
+ contentRef,
552
+ assetRefs: [],
553
+ contentDigest: sha256,
554
+ changedPaths: ["content"],
555
+ createdAt: now,
556
+ };
557
+ const content = {
558
+ contentRef,
559
+ extension,
560
+ mediaType: format.mediaType,
561
+ byteSize: bytes.byteLength,
562
+ sha256,
563
+ };
564
+ const root = artifactRoot(home);
565
+ const stagingName = `.staging-${artifactId}-${randomUUID().replaceAll("-", "")}`;
566
+ const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName]);
567
+ ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName, "revisions", revisionId]);
568
+ writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], contentRef), bytes);
569
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
570
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
571
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName], "metadata.json"), `${JSON.stringify(artifact, null, 2)}\n`);
572
+ activateStaging(root, staging, artifactId);
573
+ return getArtifact(home, artifactId, false);
574
+ }
575
+ function activateRevisionStaging(artifact, staging, revisionId) {
576
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
577
+ verifyDirectory(staging);
578
+ const destination = join(revisions.path, revisionId);
579
+ try {
580
+ lstatSync(destination);
581
+ throw storeError("ARTIFACT_CORRUPT", "a Revision id collision occurred; retry the commit");
582
+ }
583
+ catch (error) {
584
+ if (error?.code !== "ENOENT")
585
+ throw error;
586
+ }
587
+ renameSync(staging.path, destination);
588
+ const activated = lstatSync(destination);
589
+ if (!activated.isDirectory()
590
+ || activated.isSymbolicLink()
591
+ || activated.dev !== staging.dev
592
+ || activated.ino !== staging.ino
593
+ || realpathSync.native(destination) !== destination)
594
+ throw storeError("ARTIFACT_CORRUPT", "Revision activation changed the staged directory identity");
595
+ verifyDirectory(revisions);
596
+ syncDirectory(revisions.path);
597
+ }
598
+ function commitPreparedRevision(home, input) {
599
+ const artifact = artifactDirectory(home, input.artifactId);
600
+ const metadata = readArtifactMetadata(artifact);
601
+ if (metadata.artifact.currentRevisionId !== input.baseRevisionId) {
602
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
603
+ }
604
+ readRevisionDetails(artifact, metadata.artifact.currentRevisionId, true);
605
+ const format = FORMATS.get(input.extension);
606
+ if (!format || format.kind !== metadata.artifact.kind || format.mediaType !== input.mediaType) {
607
+ throw storeError("ARTIFACT_INVALID_INPUT", "the committed file kind does not match the Artifact");
608
+ }
609
+ if (input.bytes.byteLength < 1 || input.bytes.byteLength > MAX_ARTIFACT_IMPORT_BYTES) {
610
+ throw storeError("ARTIFACT_INVALID_INPUT", "revision content size is outside the supported range");
611
+ }
612
+ verifyClaimedFormat(input.extension, input.bytes);
613
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
614
+ if (readdirSync(revisions.path).length >= MAX_REVISIONS) {
615
+ throw storeError("ARTIFACT_TOO_LARGE", `artifact has reached the ${MAX_REVISIONS} revision limit`);
616
+ }
617
+ const revisionId = newOpaqueId("rev");
618
+ const sha256 = createHash("sha256").update(input.bytes).digest("hex");
619
+ const contentRef = `content${input.extension}`;
620
+ const revision = {
621
+ revisionId,
622
+ artifactId: metadata.artifact.artifactId,
623
+ parentRevisionId: input.baseRevisionId,
624
+ baseRevisionId: input.baseRevisionId,
625
+ actor: input.actor,
626
+ ...(input.taskRunId ? { taskRunId: input.taskRunId } : {}),
627
+ contentRef,
628
+ assetRefs: [],
629
+ contentDigest: sha256,
630
+ changedPaths: input.changedPaths,
631
+ createdAt: new Date().toISOString(),
632
+ };
633
+ const content = {
634
+ contentRef,
635
+ extension: input.extension,
636
+ mediaType: input.mediaType,
637
+ byteSize: input.bytes.byteLength,
638
+ sha256,
639
+ };
640
+ const stagingName = `.staging-${revisionId}-${randomUUID().replaceAll("-", "")}`;
641
+ const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", metadata.artifact.artifactId, stagingName]);
642
+ writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], contentRef), input.bytes);
643
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
644
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
645
+ activateRevisionStaging(artifact, staging, revisionId);
646
+ const updated = {
647
+ ...metadata.artifact,
648
+ currentRevisionId: revisionId,
649
+ };
650
+ try {
651
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId], "metadata.json"), `${JSON.stringify(updated, null, 2)}\n`, { expectedText: metadata.text });
652
+ }
653
+ catch (error) {
654
+ if (error instanceof PrivateStateConflictError) {
655
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed while this revision was committing; reopen the latest version", error);
656
+ }
657
+ throw error;
658
+ }
659
+ return getArtifact(home, metadata.artifact.artifactId);
660
+ }
661
+ export async function commitArtifact(home, input) {
662
+ const artifactId = checkedArtifactId(input.artifactId);
663
+ const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
664
+ const actor = checkedActor(input.actor);
665
+ const taskRunId = checkedTaskRunId(input.taskRunId);
666
+ const changedPaths = checkedChangedPaths(input.changedPaths);
667
+ const before = getArtifact(home, artifactId);
668
+ if (before.artifact.currentRevisionId !== baseRevisionId) {
669
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
670
+ }
671
+ const { sourcePath, extension, format } = sourceFormat(input.sourcePath, before.artifact.kind);
672
+ const bytes = await readImportSource(sourcePath);
673
+ verifyClaimedFormat(extension, bytes);
674
+ return commitPreparedRevision(home, {
675
+ artifactId,
676
+ baseRevisionId,
677
+ extension,
678
+ mediaType: format.mediaType,
679
+ bytes,
680
+ actor,
681
+ ...(taskRunId ? { taskRunId } : {}),
682
+ changedPaths,
683
+ });
684
+ }
685
+ export function revertArtifact(home, input) {
686
+ const artifactId = checkedArtifactId(input.artifactId);
687
+ const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
688
+ const targetRevisionId = checkedInputRevisionId(input.targetRevisionId, "targetRevisionId");
689
+ const actor = checkedActor(input.actor);
690
+ const taskRunId = checkedTaskRunId(input.taskRunId);
691
+ if (baseRevisionId === targetRevisionId) {
692
+ throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is already the current revision");
693
+ }
694
+ const artifact = artifactDirectory(home, artifactId);
695
+ const current = readArtifactMetadata(artifact).artifact;
696
+ if (current.currentRevisionId !== baseRevisionId) {
697
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this revert started; reopen the latest version");
698
+ }
699
+ const history = listArtifactRevisions(home, artifactId);
700
+ const byId = new Map(history.map((revision) => [revision.revisionId, revision]));
701
+ let cursor = byId.get(baseRevisionId);
702
+ let targetIsAncestor = false;
703
+ for (let count = 0; cursor && count <= history.length; count++) {
704
+ if (cursor.revisionId === targetRevisionId) {
705
+ targetIsAncestor = true;
706
+ break;
707
+ }
708
+ cursor = cursor.parentRevisionId ? byId.get(cursor.parentRevisionId) : undefined;
709
+ }
710
+ if (!targetIsAncestor) {
711
+ throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is not an ancestor of the current revision");
712
+ }
713
+ const target = readRevisionDetails(artifact, targetRevisionId, true);
714
+ const bytes = readContentBytes(revisionDirectory(artifact, targetRevisionId), target.content);
715
+ return commitPreparedRevision(home, {
716
+ artifactId,
717
+ baseRevisionId,
718
+ extension: target.content.extension,
719
+ mediaType: target.content.mediaType,
720
+ bytes,
721
+ actor,
722
+ ...(taskRunId ? { taskRunId } : {}),
723
+ changedPaths: ["content"],
724
+ });
725
+ }
726
+ export function getArtifact(home, artifactId, verifyDigest = true) {
727
+ return readArtifactDetailsFromDirectory(artifactDirectory(home, artifactId), verifyDigest);
728
+ }
729
+ export function listArtifacts(home) {
730
+ const root = artifactRoot(home);
731
+ verifyDirectory(root);
732
+ const entries = readdirSync(root.path, { withFileTypes: true });
733
+ const candidates = entries.filter((entry) => !entry.name.startsWith("."));
734
+ const artifacts = [];
735
+ let invalid = 0;
736
+ for (const entry of candidates.slice(0, MAX_ARTIFACTS)) {
737
+ if (!entry.isDirectory() || !ARTIFACT_ID.test(entry.name)) {
738
+ invalid += 1;
739
+ continue;
740
+ }
741
+ try {
742
+ const directory = childDirectory(root, entry.name, `no artifact ${entry.name}`);
743
+ const details = readArtifactDetailsFromDirectory(directory, false);
744
+ artifacts.push({
745
+ artifactId: details.artifact.artifactId,
746
+ kind: details.artifact.kind,
747
+ title: details.artifact.title,
748
+ currentRevisionId: details.artifact.currentRevisionId,
749
+ updatedAt: details.currentRevision.createdAt,
750
+ extension: details.content.extension,
751
+ mediaType: details.content.mediaType,
752
+ byteSize: details.content.byteSize,
753
+ });
754
+ }
755
+ catch {
756
+ invalid += 1;
757
+ }
758
+ }
759
+ verifyDirectory(root);
760
+ artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.artifactId.localeCompare(b.artifactId));
761
+ return {
762
+ artifacts,
763
+ invalid,
764
+ truncated: candidates.length > MAX_ARTIFACTS,
765
+ };
766
+ }
767
+ export function listArtifactRevisions(home, artifactId) {
768
+ const artifact = artifactDirectory(home, artifactId);
769
+ const metadata = parseJsonFile(join(artifact.path, "metadata.json"));
770
+ if (!isArtifactRecord(metadata) || metadata.artifactId !== artifactId) {
771
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
772
+ }
773
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
774
+ const entries = readdirSync(revisions.path, { withFileTypes: true });
775
+ if (entries.length > MAX_REVISIONS) {
776
+ throw storeError("ARTIFACT_CORRUPT", `artifact has more than ${MAX_REVISIONS} revisions`);
777
+ }
778
+ const out = [];
779
+ for (const entry of entries) {
780
+ if (!entry.isDirectory() || !REVISION_ID.test(entry.name)) {
781
+ throw storeError("ARTIFACT_CORRUPT", "artifact contains an invalid revision entry");
782
+ }
783
+ const directory = childDirectory(revisions, entry.name, `no revision ${entry.name}`);
784
+ const value = parseJsonFile(join(directory.path, "revision.json"));
785
+ if (!isRevision(value) || value.revisionId !== entry.name || value.artifactId !== artifactId) {
786
+ throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its directory");
787
+ }
788
+ out.push(value);
789
+ }
790
+ verifyDirectory(revisions);
791
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.revisionId.localeCompare(b.revisionId));
792
+ }