@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
|
@@ -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 {
|
|
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);
|
package/src/context/compactor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|