mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 04:31:23 +00:00
- Use stricter regex: /^[A-Za-z0-9+/]*={0,2}$/ ensures = only at end
- Normalize URL-safe base64 to standard (- → +, _ → /)
- Added tests for padding in wrong position and URL-safe normalization
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
260 lines
7.2 KiB
TypeScript
260 lines
7.2 KiB
TypeScript
import sharp from "sharp";
|
|
import { describe, expect, it } from "vitest";
|
|
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
|
|
|
|
describe("base64 validation", () => {
|
|
it("rejects invalid base64 characters and replaces with error text", async () => {
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: "not-valid-base64!!!@#$%",
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("text");
|
|
if (out[0].type === "text") {
|
|
expect(out[0].text).toContain("omitted image payload");
|
|
expect(out[0].text).toContain("invalid");
|
|
}
|
|
});
|
|
|
|
it("strips data URL prefix and processes valid base64", async () => {
|
|
// Create a small valid image
|
|
const jpeg = await sharp({
|
|
create: {
|
|
width: 10,
|
|
height: 10,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.jpeg()
|
|
.toBuffer();
|
|
|
|
const base64 = jpeg.toString("base64");
|
|
const dataUrl = `data:image/jpeg;base64,${base64}`;
|
|
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: dataUrl,
|
|
mimeType: "image/jpeg",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("image");
|
|
});
|
|
|
|
it("rejects base64 with invalid padding", async () => {
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: "SGVsbG8===", // too many padding chars
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("text");
|
|
if (out[0].type === "text") {
|
|
expect(out[0].text).toContain("omitted image payload");
|
|
}
|
|
});
|
|
|
|
it("rejects base64 with padding in wrong position", async () => {
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: "SGVs=bG8=", // = in middle is invalid
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("text");
|
|
if (out[0].type === "text") {
|
|
expect(out[0].text).toContain("omitted image payload");
|
|
}
|
|
});
|
|
|
|
it("normalizes URL-safe base64 to standard base64", async () => {
|
|
// Create a small valid image
|
|
const jpeg = await sharp({
|
|
create: {
|
|
width: 10,
|
|
height: 10,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.jpeg()
|
|
.toBuffer();
|
|
|
|
// Convert to URL-safe base64 (replace + with -, / with _)
|
|
const standardBase64 = jpeg.toString("base64");
|
|
const urlSafeBase64 = standardBase64.replace(/\+/g, "-").replace(/\//g, "_");
|
|
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: urlSafeBase64,
|
|
mimeType: "image/jpeg",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("image");
|
|
});
|
|
|
|
it("rejects base64 with invalid length", async () => {
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: "AAAAA", // length 5 without padding is invalid (remainder 1)
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("text");
|
|
if (out[0].type === "text") {
|
|
expect(out[0].text).toContain("omitted image payload");
|
|
}
|
|
});
|
|
|
|
it("handles empty base64 data gracefully", async () => {
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: " ",
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
expect(out.length).toBe(1);
|
|
expect(out[0].type).toBe("text");
|
|
if (out[0].type === "text") {
|
|
expect(out[0].text).toContain("omitted empty image payload");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("tool image sanitizing", () => {
|
|
it("shrinks oversized images to <=5MB", async () => {
|
|
const width = 2800;
|
|
const height = 2800;
|
|
const raw = Buffer.alloc(width * height * 3, 0xff);
|
|
const bigPng = await sharp(raw, {
|
|
raw: { width, height, channels: 3 },
|
|
})
|
|
.png({ compressionLevel: 0 })
|
|
.toBuffer();
|
|
expect(bigPng.byteLength).toBeGreaterThan(5 * 1024 * 1024);
|
|
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: bigPng.toString("base64"),
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
const image = out.find((b) => b.type === "image");
|
|
if (!image || image.type !== "image") {
|
|
throw new Error("expected image block");
|
|
}
|
|
const size = Buffer.from(image.data, "base64").byteLength;
|
|
expect(size).toBeLessThanOrEqual(5 * 1024 * 1024);
|
|
expect(image.mimeType).toBe("image/jpeg");
|
|
}, 20_000);
|
|
|
|
it("sanitizes image arrays and reports drops", async () => {
|
|
const width = 2600;
|
|
const height = 400;
|
|
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
|
const png = await sharp(raw, {
|
|
raw: { width, height, channels: 3 },
|
|
})
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
|
|
const images = [
|
|
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
|
|
];
|
|
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
|
|
expect(dropped).toBe(0);
|
|
expect(out.length).toBe(1);
|
|
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
|
|
expect(meta.width).toBeLessThanOrEqual(2000);
|
|
expect(meta.height).toBeLessThanOrEqual(2000);
|
|
}, 20_000);
|
|
|
|
it("shrinks images that exceed max dimension even if size is small", async () => {
|
|
const width = 2600;
|
|
const height = 400;
|
|
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
|
const png = await sharp(raw, {
|
|
raw: { width, height, channels: 3 },
|
|
})
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: png.toString("base64"),
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
const image = out.find((b) => b.type === "image");
|
|
if (!image || image.type !== "image") {
|
|
throw new Error("expected image block");
|
|
}
|
|
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
|
|
expect(meta.width).toBeLessThanOrEqual(2000);
|
|
expect(meta.height).toBeLessThanOrEqual(2000);
|
|
expect(image.mimeType).toBe("image/jpeg");
|
|
}, 20_000);
|
|
|
|
it("corrects mismatched jpeg mimeType", async () => {
|
|
const jpeg = await sharp({
|
|
create: {
|
|
width: 10,
|
|
height: 10,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.jpeg()
|
|
.toBuffer();
|
|
|
|
const blocks = [
|
|
{
|
|
type: "image" as const,
|
|
data: jpeg.toString("base64"),
|
|
mimeType: "image/png",
|
|
},
|
|
];
|
|
|
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
const image = out.find((b) => b.type === "image");
|
|
if (!image || image.type !== "image") {
|
|
throw new Error("expected image block");
|
|
}
|
|
expect(image.mimeType).toBe("image/jpeg");
|
|
});
|
|
});
|