@vellumai/assistant 0.11.4-dev.202608200019.f77fe6a → 0.11.4-dev.202608201011.f81ab8d

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608200019.f77fe6a",
3
+ "version": "0.11.4-dev.202608201011.f81ab8d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Compaction must never bake invalid image bytes into the rebuilt history.
3
+ *
4
+ * Retained images are re-hydrated from the attachment store and re-optimized
5
+ * at compaction time. If that pipeline yields bytes that no longer parse as an
6
+ * image (e.g. a corrupt conversion-cache entry), embedding them would make the
7
+ * provider reject EVERY subsequent turn with a 400: the conversation wedges
8
+ * until the block is manually removed. `buildRetainedImageBlocks` drops such
9
+ * payloads instead.
10
+ */
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ buildRetainedImageBlocks,
15
+ collectImageManifest,
16
+ } from "../context/compactor.js";
17
+ import { attachInlineAttachmentToMessage } from "../persistence/attachments-store.js";
18
+ import {
19
+ addMessage,
20
+ createConversation,
21
+ } from "../persistence/conversation-crud.js";
22
+ import { getDb } from "../persistence/db-connection.js";
23
+ import { initializeDb } from "../persistence/db-init.js";
24
+
25
+ await initializeDb();
26
+
27
+ // 1x1 transparent PNG.
28
+ const PNG_1X1_BASE64 =
29
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
30
+
31
+ // Declared image/png, but the bytes are not any known image format:
32
+ // the stand-in for a corrupted payload surviving in the pipeline.
33
+ const GARBAGE_BASE64 = Buffer.from("definitely not an image").toString(
34
+ "base64",
35
+ );
36
+
37
+ function resetTables(): void {
38
+ const db = getDb();
39
+ db.run("DELETE FROM message_attachments");
40
+ db.run("DELETE FROM attachments");
41
+ db.run("DELETE FROM messages");
42
+ db.run("DELETE FROM conversations");
43
+ }
44
+
45
+ async function addImageMessage(
46
+ conversationId: string,
47
+ filename: string,
48
+ dataBase64: string,
49
+ ): Promise<void> {
50
+ const inserted = await addMessage(
51
+ conversationId,
52
+ "user",
53
+ JSON.stringify([{ type: "text", text: filename }]),
54
+ {
55
+ metadata: { provenanceTrustClass: "guardian" },
56
+ skipIndexing: true,
57
+ },
58
+ );
59
+ await attachInlineAttachmentToMessage(
60
+ inserted.id,
61
+ 0,
62
+ filename,
63
+ "image/png",
64
+ dataBase64,
65
+ );
66
+ }
67
+
68
+ describe("buildRetainedImageBlocks corrupt-bytes gate", () => {
69
+ beforeEach(resetTables);
70
+
71
+ test("valid image bytes are retained", async () => {
72
+ const conv = createConversation();
73
+ await addImageMessage(conv.id, "good.png", PNG_1X1_BASE64);
74
+ const manifest = collectImageManifest(conv.id, "guardian");
75
+
76
+ const { blocks, resolved, missing } = await buildRetainedImageBlocks(
77
+ ["good.png"],
78
+ manifest,
79
+ );
80
+
81
+ expect(resolved).toEqual(["good.png"]);
82
+ expect(missing).toEqual([]);
83
+ expect(blocks).toHaveLength(1);
84
+ expect(blocks[0].source.type).toBe("base64");
85
+ });
86
+
87
+ test("bytes that do not parse as an image are dropped, not embedded", async () => {
88
+ const conv = createConversation();
89
+ await addImageMessage(conv.id, "corrupt.png", GARBAGE_BASE64);
90
+ await addImageMessage(conv.id, "good.png", PNG_1X1_BASE64);
91
+ const manifest = collectImageManifest(conv.id, "guardian");
92
+
93
+ const { blocks, resolved, missing } = await buildRetainedImageBlocks(
94
+ ["corrupt.png", "good.png"],
95
+ manifest,
96
+ );
97
+
98
+ // The corrupt payload is dropped (not listed as manifest-missing, since
99
+ // it resolved fine; its bytes were the problem) and the valid one
100
+ // survives.
101
+ expect(resolved).toEqual(["good.png"]);
102
+ expect(missing).toEqual([]);
103
+ expect(blocks).toHaveLength(1);
104
+ });
105
+
106
+ test("a truncated JPEG (valid SOI header, no EOI) is dropped", async () => {
107
+ // A JPEG torn mid-write keeps its SOI header, so a format sniff alone
108
+ // accepts it; the gate must also require an EOI marker.
109
+ const tornJpeg = Buffer.concat([
110
+ Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]),
111
+ Buffer.from("JFIF\0"),
112
+ Buffer.alloc(64),
113
+ ]).toString("base64");
114
+ const conv = createConversation();
115
+ await addImageMessage(conv.id, "torn.jpg", tornJpeg);
116
+ const manifest = collectImageManifest(conv.id, "guardian");
117
+
118
+ const { blocks, resolved, missing } = await buildRetainedImageBlocks(
119
+ ["torn.jpg"],
120
+ manifest,
121
+ );
122
+
123
+ expect(resolved).toEqual([]);
124
+ expect(missing).toEqual([]);
125
+ expect(blocks).toHaveLength(0);
126
+ });
127
+
128
+ test("unresolvable filenames still land in missing", async () => {
129
+ const conv = createConversation();
130
+ await addImageMessage(conv.id, "good.png", PNG_1X1_BASE64);
131
+ const manifest = collectImageManifest(conv.id, "guardian");
132
+
133
+ const { blocks, resolved, missing } = await buildRetainedImageBlocks(
134
+ ["never-uploaded.png", "good.png"],
135
+ manifest,
136
+ );
137
+
138
+ expect(missing).toEqual(["never-uploaded.png"]);
139
+ expect(resolved).toEqual(["good.png"]);
140
+ expect(blocks).toHaveLength(1);
141
+ });
142
+ });
@@ -6,13 +6,16 @@
6
6
  * gated on darwin.
7
7
  */
8
8
 
9
- import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10
11
  import { tmpdir } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { beforeAll, describe, expect, test } from "bun:test";
13
14
 
14
15
  import {
15
16
  convertImageToJpeg,
17
+ hasValidJpegStructure,
18
+ isCompleteJpeg,
16
19
  isHeifImage,
17
20
  jpegFilenameFor,
18
21
  normalizeImageBase64,
@@ -131,6 +134,119 @@ describe("sniffImageMimeType", () => {
131
134
  });
132
135
  });
133
136
 
137
+ describe("isCompleteJpeg", () => {
138
+ test("accepts SOI...EOI framed payloads", () => {
139
+ expect(
140
+ isCompleteJpeg(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xff, 0xd9])),
141
+ ).toBe(true);
142
+ });
143
+
144
+ test("rejects empty, truncated, and non-JPEG payloads", () => {
145
+ expect(isCompleteJpeg(Buffer.alloc(0))).toBe(false);
146
+ // Valid head, torn tail: the poisoned-cache signature.
147
+ expect(isCompleteJpeg(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]))).toBe(
148
+ false,
149
+ );
150
+ expect(isCompleteJpeg(PNG_1PX_BYTES)).toBe(false);
151
+ });
152
+ });
153
+
154
+ // Mirrors the converter's cache-key derivation so the test can plant a
155
+ // poisoned entry at the exact path a conversion will consult.
156
+ function cacheKeyFor(bytes: Uint8Array, quality: number): string {
157
+ const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
158
+ return `${hash}-full-q${quality}`;
159
+ }
160
+
161
+ // Minimal structurally valid JPEG: SOI, APP0 (len 4, 2 payload bytes),
162
+ // SOF0 (len 5, 3 payload bytes), SOS (len 3, 1 payload byte), entropy data
163
+ // with a stuffed FF, then EOI.
164
+ function minimalJpeg(extra: {
165
+ trailing?: Uint8Array;
166
+ dropEoi?: boolean;
167
+ }): Buffer {
168
+ const parts: Uint8Array[] = [
169
+ Buffer.from([0xff, 0xd8]), // SOI
170
+ Buffer.from([0xff, 0xe0, 0x00, 0x04, 0x4a, 0x46]), // APP0
171
+ Buffer.from([0xff, 0xc0, 0x00, 0x05, 0x08, 0x00, 0x01]), // SOF0
172
+ Buffer.from([0xff, 0xda, 0x00, 0x03, 0x01]), // SOS header
173
+ Buffer.from([0x12, 0xff, 0x00, 0x34]), // entropy data with stuffed FF
174
+ ];
175
+ if (!extra.dropEoi) {
176
+ parts.push(Buffer.from([0xff, 0xd9])); // EOI
177
+ }
178
+ if (extra.trailing) {
179
+ parts.push(extra.trailing);
180
+ }
181
+ return Buffer.concat(parts);
182
+ }
183
+
184
+ describe("hasValidJpegStructure", () => {
185
+ test("accepts a JPEG with trailing bytes after the EOI marker", () => {
186
+ // Some encoders append padding or metadata past the EOI; the structural
187
+ // walk tolerates it where isCompleteJpeg's exact tail framing does not.
188
+ const padded = minimalJpeg({ trailing: Buffer.from([0x00, 0x00]) });
189
+ expect(hasValidJpegStructure(padded)).toBe(true);
190
+ expect(isCompleteJpeg(padded)).toBe(false);
191
+ });
192
+
193
+ test("accepts restart markers inside entropy-coded data", () => {
194
+ const withRst = Buffer.concat([
195
+ Buffer.from([0xff, 0xd8]),
196
+ Buffer.from([0xff, 0xc0, 0x00, 0x05, 0x08, 0x00, 0x01]), // SOF0
197
+ Buffer.from([0xff, 0xda, 0x00, 0x03, 0x01]),
198
+ Buffer.from([0x12, 0xff, 0xd0, 0x34]), // RST0 continues the scan
199
+ Buffer.from([0xff, 0xd9]),
200
+ ]);
201
+ expect(hasValidJpegStructure(withRst)).toBe(true);
202
+ });
203
+
204
+ test("rejects a truncated JPEG whose metadata segment contains FF D9", () => {
205
+ // An EXIF-style APP1 payload can embed a complete thumbnail JPEG, so an
206
+ // FF D9 pair appears inside the segment while the main image is torn. A
207
+ // raw byte search would accept this; the marker walk skips the segment
208
+ // payload and reports the truncation.
209
+ const tornWithThumbnail = Buffer.concat([
210
+ Buffer.from([0xff, 0xd8]), // SOI
211
+ Buffer.from([0xff, 0xe1, 0x00, 0x08]), // APP1, length 8
212
+ Buffer.from([0x45, 0x78, 0xff, 0xd9, 0x00, 0x00]), // payload with FF D9
213
+ Buffer.from([0xff, 0xda, 0x00, 0x03, 0x01, 0x12, 0x34]), // torn scan
214
+ ]);
215
+ expect(hasValidJpegStructure(tornWithThumbnail)).toBe(false);
216
+ });
217
+
218
+ test("rejects truncated JPEGs and non-JPEG payloads", () => {
219
+ expect(hasValidJpegStructure(minimalJpeg({ dropEoi: true }))).toBe(false);
220
+ expect(
221
+ hasValidJpegStructure(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00])),
222
+ ).toBe(false);
223
+ expect(hasValidJpegStructure(Buffer.alloc(0))).toBe(false);
224
+ expect(hasValidJpegStructure(PNG_1PX_BYTES)).toBe(false);
225
+ });
226
+
227
+ test("rejects a top-level EOI with no frame or scan", () => {
228
+ // Bare SOI+EOI is structurally walkable but carries no image data;
229
+ // providers reject it, so the gate must too.
230
+ expect(hasValidJpegStructure(Buffer.from([0xff, 0xd8, 0xff, 0xd9]))).toBe(
231
+ false,
232
+ );
233
+ // A scan without a frame header is equally undecodable.
234
+ const scanNoFrame = Buffer.concat([
235
+ Buffer.from([0xff, 0xd8]),
236
+ Buffer.from([0xff, 0xda, 0x00, 0x03, 0x01, 0x12]),
237
+ Buffer.from([0xff, 0xd9]),
238
+ ]);
239
+ expect(hasValidJpegStructure(scanNoFrame)).toBe(false);
240
+ });
241
+
242
+ test("rejects a segment length that runs past the buffer", () => {
243
+ // A declared length larger than the remaining bytes is truncation, not a
244
+ // reason to scan beyond the buffer.
245
+ const overrun = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xff, 0xff, 0x00]);
246
+ expect(hasValidJpegStructure(overrun)).toBe(false);
247
+ });
248
+ });
249
+
134
250
  describe("normalizeImageBytes passthrough", () => {
135
251
  test("non-HEIF bytes pass through untouched", async () => {
136
252
  const result = await normalizeImageBytes("image/png", PNG_1PX_BYTES);
@@ -205,6 +321,9 @@ describe.skipIf(process.platform !== "darwin")(
205
321
  const converted = await convertImageToJpeg(heicBytes);
206
322
  expect(converted).not.toBeNull();
207
323
  expect(startsWithJpegMagic(converted!)).toBe(true);
324
+ // A real encoder's output must pass the structural walk, or the
325
+ // compactor gate would drop every legitimately converted image.
326
+ expect(hasValidJpegStructure(converted!)).toBe(true);
208
327
  });
209
328
 
210
329
  test("conversion options produce distinct outputs (cache key isolation)", async () => {
@@ -228,6 +347,29 @@ describe.skipIf(process.platform !== "darwin")(
228
347
  expect(first!.equals(second!)).toBe(true);
229
348
  });
230
349
 
350
+ test("a poisoned (truncated) cache entry is discarded, not returned", async () => {
351
+ // Distinct quality so this test owns its cache key.
352
+ const quality = 77;
353
+ const cacheDir = join(tmpdir(), "vellum-optimized-images");
354
+ mkdirSync(cacheDir, { recursive: true });
355
+ const poisonedPath = join(
356
+ cacheDir,
357
+ `${cacheKeyFor(heicBytes, quality)}.jpg`,
358
+ );
359
+ // A torn write: valid JPEG head, no EOI tail. readFromCache must
360
+ // discard it rather than return it as the converted image.
361
+ writeFileSync(poisonedPath, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]));
362
+
363
+ const converted = await convertImageToJpeg(heicBytes, { quality });
364
+ expect(converted).not.toBeNull();
365
+ expect(isCompleteJpeg(converted!)).toBe(true);
366
+
367
+ // The poisoned entry was replaced by the re-converted result.
368
+ expect(existsSync(poisonedPath)).toBe(true);
369
+ const { readFileSync } = await import("node:fs");
370
+ expect(isCompleteJpeg(readFileSync(poisonedPath))).toBe(true);
371
+ });
372
+
231
373
  test("normalizeImageBytes converts to a JPEG master", async () => {
232
374
  const result = await normalizeImageBytes("image/heic", heicBytes);
233
375
  expect(result.converted).toBe(true);
@@ -39,6 +39,10 @@ import type {
39
39
  } from "../providers/types.js";
40
40
  import { type TrustClass } from "../runtime/actor-trust-resolver.js";
41
41
  import { resolveCapabilities } from "../runtime/capabilities.js";
42
+ import {
43
+ hasValidJpegStructure,
44
+ sniffImageMimeType,
45
+ } from "../util/image-conversion.js";
42
46
  import { getLogger } from "../util/logger.js";
43
47
  import { preModelCallSanitize } from "./outbound-sanitize.js";
44
48
  import { stripInjectionsForCompaction } from "./strip-injections.js";
@@ -865,7 +869,8 @@ function resolveTailFloorIndex(messages: Message[], tailIndex: number): number {
865
869
  // Retained-image hydration
866
870
  // ---------------------------------------------------------------------------
867
871
 
868
- async function buildRetainedImageBlocks(
872
+ /** Exported for unit testing (the corrupt-bytes drop gate). */
873
+ export async function buildRetainedImageBlocks(
869
874
  filenames: string[],
870
875
  manifest: ManifestEntry[],
871
876
  ): Promise<{ blocks: ImageContent[]; resolved: string[]; missing: string[] }> {
@@ -883,7 +888,11 @@ async function buildRetainedImageBlocks(
883
888
  missing.push(name);
884
889
  continue;
885
890
  }
886
- const sourceMime = guessMimeFromFilename(name);
891
+ // Sniff the declared MIME from the stored bytes rather than the filename:
892
+ // clients derive extensions from user input, and providers reject an image
893
+ // whose bytes disagree with the declared media type.
894
+ const sourceMime =
895
+ sniffImageMimeType(content) ?? guessMimeFromFilename(name);
887
896
  // Run the same downscale pass the agent uses when first sending an
888
897
  // image. Without this, attachments that exceed the provider's per-image
889
898
  // byte limit (Anthropic: 5 MB) crash the next turn after compaction.
@@ -891,6 +900,26 @@ async function buildRetainedImageBlocks(
891
900
  content.toString("base64"),
892
901
  sourceMime,
893
902
  );
903
+ // Last-line gate before the bytes are baked into the rebuilt history: a
904
+ // corrupt payload would be rejected by the provider with a 400 on EVERY
905
+ // subsequent turn, wedging the conversation. Dropping the image loses a
906
+ // retained picture; shipping it loses the conversation. Format sniffing
907
+ // alone is not enough for JPEG: a truncated JPEG (e.g. persisted from a
908
+ // torn conversion-cache read) keeps its SOI header, so JPEG payloads must
909
+ // also walk to a terminal EOI marker.
910
+ const optimizedBytes = Buffer.from(optimized.data, "base64");
911
+ const optimizedFormat = sniffImageMimeType(optimizedBytes);
912
+ const bytesAreValidImage =
913
+ optimizedFormat != null &&
914
+ (optimizedFormat !== "image/jpeg" ||
915
+ hasValidJpegStructure(optimizedBytes));
916
+ if (!bytesAreValidImage) {
917
+ log.warn(
918
+ { filename: name, attachmentId: entry.attachmentId },
919
+ "Retained image bytes are not a valid image after transport optimization; dropping",
920
+ );
921
+ continue;
922
+ }
894
923
  blocks.push({
895
924
  type: "image",
896
925
  source: {
@@ -22,6 +22,7 @@ import {
22
22
  readdirSync,
23
23
  readFileSync,
24
24
  readSync,
25
+ renameSync,
25
26
  statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
@@ -42,26 +43,151 @@ function getCacheDir(): string {
42
43
  return join(tmpdir(), "vellum-optimized-images");
43
44
  }
44
45
 
46
+ /**
47
+ * Structural completeness check for a JPEG payload: SOI marker (FF D8) at the
48
+ * head and EOI marker (FF D9) at the tail. Every cache entry and every sips
49
+ * output is a JPEG, so a payload failing this is truncated or empty (the
50
+ * signature of a torn cache write) and must never reach a provider, which
51
+ * rejects it with a 400 that wedges the conversation (the corrupt block is
52
+ * resent on every subsequent turn).
53
+ */
54
+ export function isCompleteJpeg(bytes: Uint8Array): boolean {
55
+ return (
56
+ bytes.length >= 4 &&
57
+ bytes[0] === 0xff &&
58
+ bytes[1] === 0xd8 &&
59
+ bytes[bytes.length - 2] === 0xff &&
60
+ bytes[bytes.length - 1] === 0xd9
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Structural JPEG truncation check for payloads of arbitrary origin: walk the
66
+ * marker segments from SOI and report whether a terminal EOI is reached.
67
+ * Unlike {@link isCompleteJpeg} (exact tail framing, for sips output and
68
+ * cache entries, which always end on EOI), this tolerates encoders that
69
+ * append padding or metadata after the EOI.
70
+ *
71
+ * A raw byte search for FF D9 is not enough: length-delimited APP/COM
72
+ * segments (an EXIF thumbnail is itself a complete embedded JPEG) may contain
73
+ * FF D9, so a truncated file with an intact metadata prefix would pass.
74
+ * Walking segment boundaries skips those payloads entirely; only an EOI at
75
+ * the top level of the marker stream counts. Entropy-coded scan data is
76
+ * traversed byte-wise, where FF is stuffed as FF 00 and restart markers
77
+ * (D0-D7) continue the scan, so a marker byte there is unambiguous.
78
+ *
79
+ * The EOI only counts after at least one frame header (SOF) and one scan
80
+ * (SOS) have been seen: a degenerate payload such as bare SOI+EOI carries no
81
+ * image data and providers reject it.
82
+ */
83
+ export function hasValidJpegStructure(bytes: Uint8Array): boolean {
84
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
85
+ return false;
86
+ }
87
+ let sawFrame = false;
88
+ let sawScan = false;
89
+ let i = 2;
90
+ while (i + 1 < bytes.length) {
91
+ if (bytes[i] !== 0xff) {
92
+ return false;
93
+ }
94
+ // FF fill bytes before a marker are legal padding.
95
+ let j = i + 1;
96
+ while (j < bytes.length && bytes[j] === 0xff) {
97
+ j++;
98
+ }
99
+ if (j >= bytes.length) {
100
+ return false;
101
+ }
102
+ const marker = bytes[j];
103
+ i = j + 1;
104
+ if (marker === 0xd9) {
105
+ return sawFrame && sawScan;
106
+ }
107
+ // SOF0-SOF15 occupy C0-CF, excluding DHT (C4), JPG (C8), and DAC (CC).
108
+ if (
109
+ marker >= 0xc0 &&
110
+ marker <= 0xcf &&
111
+ marker !== 0xc4 &&
112
+ marker !== 0xc8 &&
113
+ marker !== 0xcc
114
+ ) {
115
+ sawFrame = true;
116
+ }
117
+ // Standalone markers carry no length field: repeated SOI, TEM, RST0-7.
118
+ if (
119
+ marker === 0xd8 ||
120
+ marker === 0x01 ||
121
+ (marker >= 0xd0 && marker <= 0xd7)
122
+ ) {
123
+ continue;
124
+ }
125
+ if (i + 1 >= bytes.length) {
126
+ return false;
127
+ }
128
+ const segmentLength = (bytes[i] << 8) | bytes[i + 1];
129
+ if (segmentLength < 2) {
130
+ return false;
131
+ }
132
+ i += segmentLength;
133
+ if (marker === 0xda) {
134
+ sawScan = true;
135
+ // SOS: entropy-coded data follows the header. Scan to the next real
136
+ // marker; FF 00 (stuffed data byte) and FF D0-D7 (restart) stay inside
137
+ // the scan.
138
+ while (i + 1 < bytes.length) {
139
+ if (
140
+ bytes[i] === 0xff &&
141
+ bytes[i + 1] !== 0x00 &&
142
+ !(bytes[i + 1] >= 0xd0 && bytes[i + 1] <= 0xd7)
143
+ ) {
144
+ break;
145
+ }
146
+ i++;
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
45
153
  function readFromCache(key: string): Buffer | null {
154
+ const cachePath = join(getCacheDir(), `${key}.jpg`);
46
155
  try {
47
- const cachePath = join(getCacheDir(), `${key}.jpg`);
48
156
  if (!existsSync(cachePath)) {
49
157
  return null;
50
158
  }
51
- return readFileSync(cachePath) as Buffer;
159
+ const bytes = readFileSync(cachePath) as Buffer;
160
+ if (!isCompleteJpeg(bytes)) {
161
+ // Poisoned entry (torn write, disk full, etc.): drop it so the caller
162
+ // re-converts and re-caches.
163
+ unlinkSync(cachePath);
164
+ return null;
165
+ }
166
+ return bytes;
52
167
  } catch {
53
168
  return null;
54
169
  }
55
170
  }
56
171
 
57
172
  function writeToCache(key: string, convertedBytes: Buffer): void {
173
+ const dir = getCacheDir();
174
+ // Write-then-rename so concurrent readers (the cache dir is shared across
175
+ // daemon processes) never observe a partially written entry; rename within
176
+ // the same directory is atomic. The pid+uuid suffix keeps concurrent
177
+ // writers of the same key off each other's temp file.
178
+ const tmpPath = join(dir, `${key}.${process.pid}-${uuid().slice(0, 8)}.tmp`);
58
179
  try {
59
- const dir = getCacheDir();
60
180
  mkdirSync(dir, { recursive: true });
61
- writeFileSync(join(dir, `${key}.jpg`), convertedBytes);
181
+ writeFileSync(tmpPath, convertedBytes);
182
+ renameSync(tmpPath, join(dir, `${key}.jpg`));
62
183
  evictIfNeeded(dir);
63
184
  } catch {
64
185
  // Cache write failure is non-fatal.
186
+ try {
187
+ unlinkSync(tmpPath);
188
+ } catch {
189
+ /* ignore */
190
+ }
65
191
  }
66
192
  }
67
193
 
@@ -141,7 +267,14 @@ async function runSips(
141
267
  if (proc.exitCode !== 0) {
142
268
  return null;
143
269
  }
144
- return readFileSync(outPath) as Buffer;
270
+ const out = readFileSync(outPath) as Buffer;
271
+ // A zero-exit sips can still leave a truncated file (disk full, timeout
272
+ // kill racing the write). Returning it would poison the cache and every
273
+ // downstream provider call. Treat it as a failed conversion instead.
274
+ if (!isCompleteJpeg(out)) {
275
+ return null;
276
+ }
277
+ return out;
145
278
  } catch {
146
279
  return null;
147
280
  } finally {