artifacty 0.10.8 → 0.11.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/src/lib/backup.js CHANGED
@@ -1,11 +1,64 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import path from "node:path";
3
- import { createStore, loadIndex, writeIndex } from "./storage.js";
4
+ import {
5
+ countUsers,
6
+ createStore,
7
+ exportFullStoreTables,
8
+ importFullStoreTables,
9
+ loadIndex,
10
+ openDatabase,
11
+ STORE_VERSION,
12
+ writeIndex
13
+ } from "./storage.js";
4
14
 
5
- export async function exportStore(store = createStore(), outputPath) {
15
+ export const MAX_BACKUP_BYTES = 128 * 1024 * 1024;
16
+ const BUNDLE_VERSION = 2;
17
+
18
+ export async function exportStore(store = createStore(), outputPath, { scope = "artifacts" } = {}) {
6
19
  if (!outputPath) {
7
20
  throw new Error("export requires --file <path>");
8
21
  }
22
+ const normalizedScope = scope === "full" ? "full" : "artifacts";
23
+ const bundle = await buildStoreBackup(store, { scope: normalizedScope });
24
+ const resolvedOutputPath = path.resolve(outputPath);
25
+ await mkdir(path.dirname(resolvedOutputPath), { recursive: true });
26
+ const content = `${JSON.stringify(bundle, null, 2)}\n`;
27
+ if (normalizedScope === "full") {
28
+ // The full scope carries password hashes and API token hashes. Write to
29
+ // a private temp file (mode 0o600 from creation, "wx" so it fails
30
+ // instead of following a symlink or clobbering something already
31
+ // there) and rename it into place, so the destination is never briefly
32
+ // readable with the process's default (typically 0o644) mode the way a
33
+ // plain writeFile-then-chmod leaves it for the duration of the write.
34
+ const tempPath = `${resolvedOutputPath}.${randomUUID()}.tmp`;
35
+ try {
36
+ await writeFile(tempPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
37
+ await rename(tempPath, resolvedOutputPath);
38
+ } catch (error) {
39
+ // Don't leave a stray 0o600 temp file containing password/API-token
40
+ // hashes behind if the rename (or the write itself) fails.
41
+ await rm(tempPath, { force: true });
42
+ throw error;
43
+ }
44
+ } else {
45
+ await writeFile(resolvedOutputPath, content, "utf8");
46
+ }
47
+ return {
48
+ path: resolvedOutputPath,
49
+ artifactCount: bundle.artifacts.length,
50
+ scope: normalizedScope,
51
+ exportedAt: bundle.exportedAt
52
+ };
53
+ }
54
+
55
+ export async function exportStoreToString(store = createStore(), { scope = "artifacts" } = {}) {
56
+ const bundle = await buildStoreBackup(store, { scope: scope === "full" ? "full" : "artifacts" });
57
+ return `${JSON.stringify(bundle, null, 2)}\n`;
58
+ }
59
+
60
+ export async function buildStoreBackup(store = createStore(), { scope = "artifacts" } = {}) {
61
+ const normalizedScope = scope === "full" ? "full" : "artifacts";
9
62
  const index = await loadIndex(store);
10
63
  const artifacts = [];
11
64
  for (const artifact of index.artifacts) {
@@ -13,6 +66,7 @@ export async function exportStore(store = createStore(), outputPath) {
13
66
  for (const version of artifact.versions) {
14
67
  versions.push({
15
68
  ...version,
69
+ path: normalizeBackupRelativePath(version.path),
16
70
  content: await readFile(path.join(store.home, version.path), "utf8")
17
71
  });
18
72
  }
@@ -20,26 +74,121 @@ export async function exportStore(store = createStore(), outputPath) {
20
74
  }
21
75
 
22
76
  const bundle = {
77
+ bundleVersion: BUNDLE_VERSION,
78
+ scope: normalizedScope,
79
+ storeVersion: STORE_VERSION,
23
80
  schemaVersion: 1,
24
81
  exportedAt: new Date().toISOString(),
25
82
  artifacts
26
83
  };
27
- await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
28
- await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
29
- return {
30
- path: path.resolve(outputPath),
31
- artifactCount: artifacts.length,
32
- exportedAt: bundle.exportedAt
33
- };
84
+
85
+ if (normalizedScope === "full") {
86
+ bundle.full = await exportFullStoreTables(store);
87
+ }
88
+
89
+ return bundle;
34
90
  }
35
91
 
36
- export async function importStore(store = createStore(), inputPath) {
92
+ export async function importStore(store = createStore(), inputPath, options = {}) {
37
93
  if (!inputPath) {
38
94
  throw new Error("import-store requires --file <path>");
39
95
  }
40
96
  const bundle = JSON.parse(await readFile(inputPath, "utf8"));
97
+ const result = await importStoreBundle(store, bundle, options);
98
+ return {
99
+ path: path.resolve(inputPath),
100
+ ...result
101
+ };
102
+ }
103
+
104
+ export async function importStoreFromString(store = createStore(), content, options = {}) {
105
+ if (!String(content || "").trim()) {
106
+ throw new Error("Artifacty backup JSON is required");
107
+ }
108
+ let bundle;
109
+ try {
110
+ bundle = JSON.parse(content);
111
+ } catch (error) {
112
+ throw Object.assign(new Error(`Invalid Artifacty backup JSON: ${error.message}`), {
113
+ statusCode: 400,
114
+ code: "INVALID_BACKUP_JSON"
115
+ });
116
+ }
117
+ return importStoreBundle(store, bundle, options);
118
+ }
119
+
120
+ export async function importStoreBundle(store = createStore(), bundle, { confirm, forceUsers = false } = {}) {
121
+ if (!bundle || typeof bundle !== "object") {
122
+ throw Object.assign(new Error("Invalid Artifacty backup: JSON object expected"), {
123
+ statusCode: 400,
124
+ code: "INVALID_BACKUP"
125
+ });
126
+ }
41
127
  if (!Array.isArray(bundle.artifacts)) {
42
- throw new Error("Invalid Artifacty backup: artifacts array missing");
128
+ throw Object.assign(new Error("Invalid Artifacty backup: artifacts array missing"), {
129
+ statusCode: 400,
130
+ code: "INVALID_BACKUP"
131
+ });
132
+ }
133
+
134
+ // Bundles written before bundleVersion 2 (roadmap section 13) have no
135
+ // `scope` field; they were always artifacts-only.
136
+ const scope = bundle.scope === "full" ? "full" : "artifacts";
137
+ const warnings = [];
138
+
139
+ if (scope === "full") {
140
+ if (confirm !== "replace-all") {
141
+ throw Object.assign(new Error('Full-scope restore requires confirm: "replace-all"'), {
142
+ statusCode: 400,
143
+ code: "confirm_required"
144
+ });
145
+ }
146
+ if (!forceUsers) {
147
+ const existingUsers = await countUsers(store);
148
+ if (existingUsers > 0) {
149
+ throw Object.assign(new Error("Target store already has users; pass forceUsers to overwrite them"), {
150
+ statusCode: 409,
151
+ code: "users_exist"
152
+ });
153
+ }
154
+ }
155
+ // Validate bundle.full's shape *before* writeIndex below replaces the
156
+ // artifacts table and deletes version files: importFullStoreTables runs
157
+ // after that point, and a malformed bundle.full throwing partway
158
+ // through it would otherwise leave the store with the backup's
159
+ // artifacts but the *target's* original users/tokens/comments — an
160
+ // unrecoverable half-restore. This check doesn't guarantee
161
+ // importFullStoreTables can't still fail for other reasons (disk
162
+ // errors, etc.), but it catches the common case of a malformed or
163
+ // hand-edited bundle up front.
164
+ assertValidFullBackupTables(bundle.full);
165
+ if (!Array.isArray(bundle.full?.embeddings)) {
166
+ warnings.push(
167
+ "Backup predates embeddings in full-scope backups; run `artifacty index rebuild --embeddings` after this restore to rebuild semantic search."
168
+ );
169
+ }
170
+ } else {
171
+ // writeIndex's DELETE FROM artifacts cascades to every existing
172
+ // artifact's comments/relations/embeddings (foreign keys, ON DELETE
173
+ // CASCADE) — but an artifacts-scope bundle never carries or restores
174
+ // those tables. Silently wiping a target store's comment/relation
175
+ // history because someone ran an artifacts-only restore would be a
176
+ // surprising, unrecoverable data loss, so refuse unless the caller
177
+ // explicitly opts in the same way a full-scope restore does.
178
+ const dependents = countArtifactDependents(store);
179
+ if (dependents.comments > 0 || dependents.relations > 0) {
180
+ if (confirm !== "replace-all") {
181
+ throw Object.assign(
182
+ new Error(
183
+ 'Target store has comments and/or relations that this artifacts-scope restore would delete without restoring equivalents. Pass confirm: "replace-all" to proceed anyway, or use a full-scope backup.'
184
+ ),
185
+ { statusCode: 409, code: "dependents_exist" }
186
+ );
187
+ }
188
+ warnings.push(
189
+ `Target store's ${dependents.comments} comment(s) and ${dependents.relations} relation(s) were deleted by this artifacts-scope restore and are not restored (only a full-scope backup carries them).`
190
+ );
191
+ }
43
192
  }
44
193
 
45
194
  const index = {
@@ -52,8 +201,9 @@ export async function importStore(store = createStore(), inputPath) {
52
201
  for (const version of artifact.versions || []) {
53
202
  const cleanVersion = { ...version };
54
203
  delete cleanVersion.content;
204
+ cleanVersion.path = normalizeBackupRelativePath(cleanVersion.path);
55
205
  const content = version.content || "";
56
- const absolutePath = path.join(store.home, cleanVersion.path);
206
+ const absolutePath = backupVersionPath(store, cleanVersion.path);
57
207
  await mkdir(path.dirname(absolutePath), { recursive: true });
58
208
  await writeFile(absolutePath, content, "utf8");
59
209
  versions.push(cleanVersion);
@@ -62,14 +212,175 @@ export async function importStore(store = createStore(), inputPath) {
62
212
  }
63
213
 
64
214
  await writeIndex(store, index);
65
- return {
66
- path: path.resolve(inputPath),
215
+ await pruneUnreferencedArtifactFiles(store, index);
216
+
217
+ const result = {
67
218
  artifactCount: index.artifacts.length,
219
+ scope,
68
220
  importedAt: new Date().toISOString()
69
221
  };
222
+ if (warnings.length > 0) {
223
+ result.warnings = warnings;
224
+ }
225
+
226
+ if (scope === "full") {
227
+ result.tableCounts = await importFullStoreTables(store, bundle.full || {}, { forceUsers });
228
+ }
229
+
230
+ return result;
231
+ }
232
+
233
+ // Counts the rows an artifacts-scope restore would silently delete (via
234
+ // writeIndex's artifacts-table replace cascading through foreign keys) but
235
+ // never restores, so importStoreBundle can decide whether to warn/refuse.
236
+ function countArtifactDependents(store) {
237
+ const db = openDatabase(store);
238
+ try {
239
+ return {
240
+ comments: db.prepare("SELECT COUNT(*) AS count FROM artifact_comments").get().count,
241
+ relations: db.prepare("SELECT COUNT(*) AS count FROM artifact_relations").get().count
242
+ };
243
+ } finally {
244
+ db.close();
245
+ }
246
+ }
247
+
248
+ const FULL_BACKUP_TABLE_ARRAY_KEYS = [
249
+ "users",
250
+ "apiTokens",
251
+ "auditLog",
252
+ "relations",
253
+ "webhooks",
254
+ "comments",
255
+ "savedViews",
256
+ "embeddings",
257
+ "meta"
258
+ ];
259
+
260
+ // Partial, fail-fast validation of a full-scope backup's `full` object,
261
+ // aimed at catching the common failure mode (a malformed or hand-edited
262
+ // bundle) before writeIndex below has made any changes. Not exhaustive:
263
+ // importFullStoreTables (storage.js) remains the source of truth for what
264
+ // is actually importable, and can still throw for reasons this doesn't
265
+ // check for.
266
+ function assertValidFullBackupTables(full) {
267
+ if (full === undefined || full === null) {
268
+ return;
269
+ }
270
+ if (typeof full !== "object" || Array.isArray(full)) {
271
+ throw Object.assign(new Error("Invalid Artifacty backup: `full` must be an object"), {
272
+ statusCode: 400,
273
+ code: "INVALID_BACKUP"
274
+ });
275
+ }
276
+ for (const key of FULL_BACKUP_TABLE_ARRAY_KEYS) {
277
+ if (full[key] !== undefined && !Array.isArray(full[key])) {
278
+ throw Object.assign(new Error(`Invalid Artifacty backup: \`full.${key}\` must be an array`), {
279
+ statusCode: 400,
280
+ code: "INVALID_BACKUP"
281
+ });
282
+ }
283
+ }
284
+ for (const row of full.users || []) {
285
+ if (!row || typeof row !== "object" || !String(row.id || "").trim() || !String(row.email || "").trim()) {
286
+ throw Object.assign(new Error("Invalid Artifacty backup: each `full.users` entry needs an id and email"), {
287
+ statusCode: 400,
288
+ code: "INVALID_BACKUP"
289
+ });
290
+ }
291
+ }
292
+ for (const row of full.apiTokens || []) {
293
+ if (!row || typeof row !== "object" || !String(row.id || "").trim() || !String(row.userId || "").trim()) {
294
+ throw Object.assign(new Error("Invalid Artifacty backup: each `full.apiTokens` entry needs an id and userId"), {
295
+ statusCode: 400,
296
+ code: "INVALID_BACKUP"
297
+ });
298
+ }
299
+ }
70
300
  }
71
301
 
72
302
  export function defaultBackupPath(store = createStore()) {
73
303
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
74
304
  return path.join(store.home, "backups", `artifacty-${stamp}.json`);
75
305
  }
306
+
307
+ function backupVersionPath(store, relativePath) {
308
+ const normalized = normalizeBackupRelativePath(relativePath);
309
+ const root = path.resolve(store.home);
310
+ const absolute = path.resolve(root, ...normalized.split("/"));
311
+ if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
312
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${relativePath}`), {
313
+ statusCode: 400,
314
+ code: "INVALID_BACKUP_PATH"
315
+ });
316
+ }
317
+ return absolute;
318
+ }
319
+
320
+ function normalizeBackupRelativePath(relativePath) {
321
+ const value = String(relativePath || "");
322
+ if (!value || path.isAbsolute(value)) {
323
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
324
+ statusCode: 400,
325
+ code: "INVALID_BACKUP_PATH"
326
+ });
327
+ }
328
+
329
+ const portable = value.replaceAll("\\", "/");
330
+ if (/^[A-Za-z]:\//.test(portable) || portable.startsWith("//")) {
331
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
332
+ statusCode: 400,
333
+ code: "INVALID_BACKUP_PATH"
334
+ });
335
+ }
336
+
337
+ const parts = portable
338
+ .split("/")
339
+ .filter((part) => part && part !== ".");
340
+ if (parts.length === 0 || parts.some((part) => part === "..")) {
341
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
342
+ statusCode: 400,
343
+ code: "INVALID_BACKUP_PATH"
344
+ });
345
+ }
346
+ return parts.join("/");
347
+ }
348
+
349
+ async function pruneUnreferencedArtifactFiles(store, index) {
350
+ const referenced = new Set();
351
+ for (const artifact of index.artifacts) {
352
+ for (const version of artifact.versions || []) {
353
+ referenced.add(backupVersionPath(store, version.path));
354
+ }
355
+ }
356
+
357
+ const files = await listFiles(store.artifactsDir);
358
+ for (const filePath of files) {
359
+ if (!referenced.has(filePath)) {
360
+ await rm(filePath, { force: true });
361
+ }
362
+ }
363
+ }
364
+
365
+ async function listFiles(root) {
366
+ let entries;
367
+ try {
368
+ entries = await readdir(root, { withFileTypes: true });
369
+ } catch (error) {
370
+ if (error.code === "ENOENT") {
371
+ return [];
372
+ }
373
+ throw error;
374
+ }
375
+
376
+ const files = [];
377
+ for (const entry of entries) {
378
+ const fullPath = path.join(root, entry.name);
379
+ if (entry.isDirectory()) {
380
+ files.push(...await listFiles(fullPath));
381
+ } else if (entry.isFile()) {
382
+ files.push(path.resolve(fullPath));
383
+ }
384
+ }
385
+ return files;
386
+ }
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { createHash } from "node:crypto";
3
3
  import { normalizeSourceAgent } from "./agents.js";
4
- import { ARTIFACT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
4
+ import { ARTIFACT_TYPES, BUNDLE_BINARY_CONTENT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
5
5
 
6
6
  const CONTINUATION_AGENTS = new Set(["codex", "copilot", "cursor"]);
7
7
 
@@ -119,6 +119,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
119
119
  if (type.includes("vnd.ant.react") || type.includes("jsx")) {
120
120
  return "react";
121
121
  }
122
+ if (type.includes("ipynb") || type.includes("notebook")) {
123
+ return "notebook";
124
+ }
122
125
  if (type.includes("html")) {
123
126
  return "html";
124
127
  }
@@ -126,9 +129,12 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
126
129
  return "markdown";
127
130
  }
128
131
  if (type.includes("json")) {
129
- return "json";
132
+ return looksLikeNotebook(content) ? "notebook" : "json";
130
133
  }
131
134
  const lowerName = optionalString(fileName).toLowerCase();
135
+ if (lowerName.endsWith(".ipynb")) {
136
+ return "notebook";
137
+ }
132
138
  if (lowerName.endsWith(".sarif") || lowerName.endsWith(".sarif.json")) {
133
139
  return "sarif";
134
140
  }
@@ -161,6 +167,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
161
167
  if (extension === ".jsx" || extension === ".tsx") {
162
168
  return "react";
163
169
  }
170
+ if (extension === ".ipynb") {
171
+ return "notebook";
172
+ }
164
173
  if (isCodeExtension(extension)) {
165
174
  return "code";
166
175
  }
@@ -190,6 +199,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
190
199
  if (looksLikeSarif(trimmed)) {
191
200
  return "sarif";
192
201
  }
202
+ if (looksLikeNotebook(trimmed)) {
203
+ return "notebook";
204
+ }
193
205
  if (looksLikeJson(trimmed)) {
194
206
  return "json";
195
207
  }
@@ -248,6 +260,21 @@ function decodeObjectPayload(value, agent) {
248
260
  };
249
261
  }
250
262
 
263
+ if (isNotebookObject(artifactObject)) {
264
+ return {
265
+ content: JSON.stringify(artifactObject, null, 2),
266
+ format: "notebook",
267
+ contentType: "application/x-ipynb+json; charset=utf-8",
268
+ title: artifactObject.title || artifactObject.name || "Notebook",
269
+ sourceAgent: artifactObject.sourceAgent || artifactObject.source_agent || artifactObject.agent || (agent === "auto" ? undefined : agent),
270
+ artifactType: artifactObject.artifactType || artifactObject.artifact_type || "analysis-report",
271
+ tags: artifactObject.tags,
272
+ metadata: {
273
+ originalPayloadShape: "notebook"
274
+ }
275
+ };
276
+ }
277
+
251
278
  if (typeof artifactObject.content === "string" && !Array.isArray(artifactObject.content)) {
252
279
  const sourceContentType = artifactObject.contentType ||
253
280
  artifactObject.content_type ||
@@ -440,13 +467,30 @@ function decodeBundlePayload(object, agent) {
440
467
  ? collectContinuationMetadata(continuationAgent, { agent: sourceAgent }, bundle, object)
441
468
  : {};
442
469
  const normalizedFiles = files.map((file, index) => {
470
+ const filePath = optionalString(file.path || file.name || `file-${index + 1}`);
471
+ const declaredContentType = optionalString(file.contentType || file.mimeType).toLowerCase().split(";")[0].trim();
472
+ const isBinary = file.encoding === "base64" || BUNDLE_BINARY_CONTENT_TYPES.has(declaredContentType);
473
+
474
+ if (isBinary) {
475
+ const base64 = normalizeBase64(typeof file.content === "string" ? file.content : "");
476
+ const decoded = base64 ? Buffer.from(base64, "base64") : Buffer.alloc(0);
477
+ return {
478
+ path: filePath,
479
+ content: base64,
480
+ encoding: "base64",
481
+ contentType: declaredContentType || "application/octet-stream",
482
+ sizeBytes: decoded.byteLength,
483
+ sha256: createHash("sha256").update(decoded).digest("hex")
484
+ };
485
+ }
486
+
443
487
  const content = typeof file.content === "string" ? file.content : "";
444
488
  return {
445
- path: optionalString(file.path || file.name || `file-${index + 1}`),
489
+ path: filePath,
446
490
  content,
447
491
  contentType: optionalString(file.contentType || file.mimeType) || contentTypeForFormat(detectFormat({
448
492
  content,
449
- fileName: file.path || file.name
493
+ fileName: filePath
450
494
  })),
451
495
  sizeBytes: Buffer.byteLength(content, "utf8"),
452
496
  sha256: createHash("sha256").update(content).digest("hex")
@@ -1098,7 +1142,7 @@ function inferArtifactType({ format, content, fileName, metadata }) {
1098
1142
  if (format === "code") {
1099
1143
  return "snippet";
1100
1144
  }
1101
- if (format === "sarif") {
1145
+ if (format === "sarif" || format === "notebook") {
1102
1146
  return "analysis-report";
1103
1147
  }
1104
1148
  if (format === "csv") {
@@ -1190,6 +1234,27 @@ function isSarifObject(value) {
1190
1234
  (typeof value.version === "string" || optionalString(value.$schema).toLowerCase().includes("sarif"));
1191
1235
  }
1192
1236
 
1237
+ function looksLikeNotebook(value) {
1238
+ if (!looksLikeJson(value)) {
1239
+ return false;
1240
+ }
1241
+ try {
1242
+ return isNotebookObject(JSON.parse(optionalString(value)));
1243
+ } catch {
1244
+ return false;
1245
+ }
1246
+ }
1247
+
1248
+ export function isNotebookObject(value) {
1249
+ return Boolean(
1250
+ value &&
1251
+ typeof value === "object" &&
1252
+ !Array.isArray(value) &&
1253
+ value.nbformat !== undefined &&
1254
+ Array.isArray(value.cells)
1255
+ );
1256
+ }
1257
+
1193
1258
  function looksLikeHtml(value) {
1194
1259
  const trimmed = optionalString(value);
1195
1260
  if (/^<!doctype html/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)) {
@@ -1420,3 +1485,32 @@ function optionalString(value) {
1420
1485
  }
1421
1486
  return String(value).trim();
1422
1487
  }
1488
+
1489
+ // Resolves the best available image/video content type for a stored
1490
+ // artifact: an explicit media MIME type in version metadata, then the
1491
+ // version's own contentType if it is itself image/video, then a
1492
+ // format-based fallback. Used when serving raw media bytes (e.g. GET
1493
+ // /artifacts/:id/raw) so a stale or missing contentType still renders.
1494
+ export function mediaContentType(artifact) {
1495
+ const metadataType = String(artifact.version.metadata?.mimeType || "").toLowerCase();
1496
+ if (metadataType.startsWith("image/") || metadataType.startsWith("video/")) {
1497
+ return metadataType;
1498
+ }
1499
+ const versionType = String(artifact.version.contentType || "").toLowerCase().split(";")[0];
1500
+ if (versionType.startsWith("image/") || versionType.startsWith("video/")) {
1501
+ return versionType;
1502
+ }
1503
+ return artifact.version.format === "video" ? "video/mp4" : "image/png";
1504
+ }
1505
+
1506
+ // Decodes an image/video artifact's stored content (a data: URL or bare
1507
+ // base64 string) into raw bytes, or returns null if it isn't valid base64.
1508
+ export function decodeMediaContent(content) {
1509
+ const value = String(content || "").trim();
1510
+ const dataUrl = /^data:[^;,]+;base64,([A-Za-z0-9+/=_-\s]+)$/i.exec(value);
1511
+ const base64 = (dataUrl ? dataUrl[1] : value).replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
1512
+ if (!base64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
1513
+ return null;
1514
+ }
1515
+ return Buffer.from(base64, "base64");
1516
+ }
package/src/lib/csv.js ADDED
@@ -0,0 +1,51 @@
1
+ // Single shared CSV parser used by the CSV renderer (render.js), the CSV
2
+ // diff (diff.js), and the SARIF/CSV export route (sarif-csv-export.js). All
3
+ // three previously carried byte-identical copies of this state machine that
4
+ // had already diverged in behavior on malformed input (an unterminated
5
+ // quoted field): the render.js copy failed closed while the diff.js copy
6
+ // silently emitted a bogus single-cell row for the same input, so the same
7
+ // artifact rendered as an error but diffed as data. This module is the one
8
+ // source of truth: it always fails closed on an unterminated quoted field.
9
+ export function parseCsv(content) {
10
+ const text = String(content || "");
11
+ const rows = [];
12
+ let row = [];
13
+ let cell = "";
14
+ let inQuotes = false;
15
+
16
+ for (let index = 0; index < text.length; index += 1) {
17
+ const char = text[index];
18
+ if (char === "\"") {
19
+ if (inQuotes && text[index + 1] === "\"") {
20
+ cell += "\"";
21
+ index += 1;
22
+ } else {
23
+ inQuotes = !inQuotes;
24
+ }
25
+ } else if (char === "," && !inQuotes) {
26
+ row.push(cell);
27
+ cell = "";
28
+ } else if ((char === "\n" || char === "\r") && !inQuotes) {
29
+ if (char === "\r" && text[index + 1] === "\n") {
30
+ index += 1;
31
+ }
32
+ row.push(cell);
33
+ if (row.length > 1 || row[0] !== "") {
34
+ rows.push(row);
35
+ }
36
+ row = [];
37
+ cell = "";
38
+ } else {
39
+ cell += char;
40
+ }
41
+ }
42
+
43
+ if (inQuotes) {
44
+ return { ok: false, error: "unterminated quoted field", rows: [] };
45
+ }
46
+ row.push(cell);
47
+ if (row.length > 1 || row[0] !== "") {
48
+ rows.push(row);
49
+ }
50
+ return { ok: true, rows };
51
+ }