fix: release QA hardening across processing, media, security, and CI gates (#649)

A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
This commit is contained in:
SnapOtter
2026-07-27 15:37:30 +08:00
committed by GitHub
parent bc32f86a07
commit d10d0f544f
855 changed files with 54564 additions and 13092 deletions
@@ -104,6 +104,15 @@ describe("compress: format selection", () => {
const out = await (await compress(sharp(svg), { quality: 80 })).toBuffer();
expect(await outputFormat(out)).toBe("png");
});
it.each(["bmp", "heic", "qoi", "../../outside"])(
"rejects a runtime-unsupported explicit format (%s)",
async (format) => {
await expect(
compress(sharp(photoPng), { quality: 80, format: format as "jpg" }),
).rejects.toThrow(`Unsupported compression format: ${format}`);
},
);
});
describe("compress: quality controls output size", () => {
@@ -148,22 +157,39 @@ describe("compress: quality clamp boundaries", () => {
/between 1 and 100/,
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional quality (%s)",
async (quality) => {
await expect(compress(sharp(photoPng), { quality, format: "jpg" })).rejects.toThrow(
"Quality must be an integer between 1 and 100",
);
},
);
});
describe("compress: target size", () => {
it("rejects a non-positive target and accepts the smallest positive target", async () => {
it("rejects non-positive and unreachable targets", async () => {
await expect(compress(sharp(photoPng), { targetSizeBytes: 0, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
await expect(compress(sharp(photoPng), { targetSizeBytes: -5, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
// target=1 is > 0, so it must NOT throw (kills a `<= 0` -> `< 0` mutant).
await expect(
compress(sharp(photoPng), { targetSizeBytes: 1, format: "jpg" }),
).resolves.toBeDefined();
await expect(compress(sharp(photoPng), { targetSizeBytes: 1, format: "jpg" })).rejects.toThrow(
"Unable to compress image to 1 bytes within safe resize limits",
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional target (%s)",
async (targetSizeBytes) => {
await expect(compress(sharp(photoPng), { targetSizeBytes, format: "jpg" })).rejects.toThrow(
"Target size must be a positive integer",
);
},
);
it("hits a reachable target without downscaling", async () => {
// Target comfortably above the q=1 full-size floor: the quality search
// succeeds, dimensions stay full, and the result fits under the target.
@@ -190,6 +216,7 @@ describe("compress: target size", () => {
expect(meta.height).toBeLessThan(400);
// The fallback should still shrink the file well below the original.
expect(out.length).toBeLessThan(photoPng.length);
expect(out.length).toBeLessThanOrEqual(target);
});
it("a smaller target produces a smaller (or equal) file than a larger target", async () => {
@@ -207,15 +207,11 @@ describe("compress downscale pass (L110, L117)", () => {
// 6x13; the whole-condition `-> false` mutant never breaks and shrinks to 2x4;
// the `newWidth < 10 -> false` operand mutant loses the width guard so 8x17 no
// longer breaks. All three change the exact output dimensions.
it("stops the downscale loop when the width axis hits the floor (kills L110 width operand)", async () => {
it("fails clearly when the width axis reaches the floor before the target is met", async () => {
const asymmetric = await seededPhoto(20, 40, 55555, 3, 2, 120);
const result = await compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(11);
expect(info.height).toBe(23);
// The 10px floor is respected on both axes: dimensions never drop below 10.
expect(info.width).toBeGreaterThanOrEqual(10);
expect(info.height).toBeGreaterThanOrEqual(10);
await expect(
compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" }),
).rejects.toThrow("Unable to compress image to 1 bytes within safe resize limits");
});
// Transposed source (40x20): the passes are 30x15, 23x11, then 17x8 which trips
@@ -224,25 +220,21 @@ describe("compress downscale pass (L110, L117)", () => {
// mutant loses the height guard, so 17x8 no longer breaks and the output shrinks
// further. The width-axis case above cannot catch this operand; only a
// height-limited source can.
it("stops the downscale loop when the height axis hits the floor (kills L110 height operand)", async () => {
it("fails clearly when the height axis reaches the floor before the target is met", async () => {
const asymmetric = await seededPhoto(40, 20, 55555, 3, 2, 120);
const result = await compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(23);
expect(info.height).toBe(11);
expect(info.width).toBeGreaterThanOrEqual(10);
expect(info.height).toBeGreaterThanOrEqual(10);
await expect(
compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" }),
).rejects.toThrow("Unable to compress image to 1 bytes within safe resize limits");
});
// A 13x13 source scales to exactly 10x10 on the first pass. With `< 10`
// (correct) that is NOT below the floor, so the loop continues and the final
// fallback returns 10x10. The L110 Equality `< -> <=` mutant treats 10 as below
// the floor, breaks on pass 1, and returns the un-scaled 13x13 instead.
it("keeps a dimension that lands exactly on 10 (kills L110 equality)", async () => {
it("does not claim success when the 10px floor still exceeds the target", async () => {
const tiny = await seededPhoto(13, 13, 987654321, 3, 2, 120);
const result = await compress(sharp(tiny), { targetSizeBytes: 1, format: "jpg" });
const info = await outputInfo(result);
expect(info.width).toBe(10);
expect(info.height).toBe(10);
await expect(compress(sharp(tiny), { targetSizeBytes: 1, format: "jpg" })).rejects.toThrow(
"Unable to compress image to 1 bytes within safe resize limits",
);
});
});
@@ -183,11 +183,9 @@ describe("detectFormat via magic bytes", () => {
expect(await detectFormat(buf)).toBe("unknown");
});
it("returns webp for a RIFF header shorter than 12 (signature check skipped)", async () => {
// Documents the exact code path: the `buffer.length >= 12` guard is false,
// so the WEBP-signature verification is skipped and RIFF alone yields webp.
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 11))).toBe("webp");
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 4))).toBe("webp");
it("returns unknown for a RIFF header too short to carry the WEBP signature", async () => {
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 11))).toBe("unknown");
expect(await detectFormat(bytes([0x52, 0x49, 0x46, 0x46], 4))).toBe("unknown");
});
});
@@ -1,6 +1,6 @@
import exifReader from "exif-reader";
import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { editMetadata } from "../src/operations/edit-metadata.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import type { Sharp } from "../src/types.js";
@@ -14,8 +14,10 @@ const richImage = (): Sharp => sharp(richBuffer);
interface ExifSections {
hasExif: boolean;
hasIcc: boolean;
hasXmp: boolean;
image: NonNullable<ReturnType<typeof exifReader>["Image"]>;
photo: NonNullable<ReturnType<typeof exifReader>["Photo"]>;
gps: NonNullable<ReturnType<typeof exifReader>["GPSInfo"]>;
}
// Encode the pipeline to a JPEG buffer and decode its EXIF/ICC so we can assert
@@ -23,12 +25,14 @@ interface ExifSections {
async function readBack(image: Sharp): Promise<ExifSections> {
const buf = await image.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = meta.exif ? exifReader(meta.exif) : { Image: {}, Photo: {} };
const parsed = meta.exif ? exifReader(meta.exif) : {};
return {
hasExif: !!meta.exif,
hasIcc: !!meta.icc,
hasXmp: !!meta.xmp,
image: parsed.Image ?? {},
photo: parsed.Photo ?? {},
gps: parsed.GPSInfo ?? {},
};
}
@@ -51,8 +55,17 @@ beforeAll(async () => {
IFD2: {
DateTimeOriginal: "2001:01:01 01:01:01",
},
IFD3: {
GPSLatitudeRef: "N",
GPSLatitude: "40 30 0",
GPSLongitudeRef: "W",
GPSLongitude: "74 0 0",
},
})
.withIccProfile("srgb")
.withXmp(
'<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="SnapOtter XMP"/></rdf:RDF></x:xmpmeta><?xpacket end="w"?>',
)
.jpeg()
.toBuffer();
});
@@ -143,7 +156,7 @@ describe("editMetadata", () => {
});
it("removes a requested field via fieldsToRemove while keeping the rest", async () => {
const { image } = await readBack(
const { gps, hasIcc, hasXmp, image } = await readBack(
await editMetadata(richImage(), { fieldsToRemove: ["Software"] }),
);
// Removed field is gone...
@@ -152,6 +165,12 @@ describe("editMetadata", () => {
expect(image.Artist).toBe("OrigArtist");
expect(image.Copyright).toBe("OrigCopyright");
expect(image.ImageDescription).toBe("OrigDesc");
expect(gps.GPSLatitudeRef).toBe("N");
expect(gps.GPSLatitude).toEqual([40, 30, 0]);
expect(gps.GPSLongitudeRef).toBe("W");
expect(gps.GPSLongitude).toEqual([74, 0, 0]);
expect(hasIcc).toBe(true);
expect(hasXmp).toBe(true);
});
it("removes a field AND applies an edit in the same call (withExif rebuild path)", async () => {
@@ -194,11 +213,36 @@ describe("editMetadata", () => {
it("treats clearGps:true as a removal trigger, rebuilding EXIF while keeping fields", async () => {
// clearGps flips hasRemovals true even with an empty fieldsToRemove, so the
// function rebuilds EXIF from the source rather than taking keepMetadata().
const { hasExif, image } = await readBack(await editMetadata(richImage(), { clearGps: true }));
const { gps, hasExif, image } = await readBack(
await editMetadata(richImage(), { clearGps: true }),
);
expect(hasExif).toBe(true);
// Non-GPS IFD0 fields survive the rebuild verbatim.
expect(image.Artist).toBe("OrigArtist");
expect(image.Copyright).toBe("OrigCopyright");
expect(gps).toEqual({});
});
it("removes a specifically requested GPS field while preserving sibling GPS fields", async () => {
const { gps } = await readBack(
await editMetadata(richImage(), { fieldsToRemove: ["GPSLatitude"] }),
);
expect(gps.GPSLatitude).toBeUndefined();
expect(gps.GPSLongitudeRef).toBe("W");
expect(gps.GPSLongitude).toEqual([74, 0, 0]);
});
it("fails safely when existing EXIF cannot be parsed during a removal", async () => {
const withExif = vi.fn();
const fake = {
metadata: vi.fn().mockResolvedValue({ exif: Buffer.from("not-valid-exif") }),
withExif,
} as unknown as Sharp;
await expect(editMetadata(fake, { fieldsToRemove: ["Software"] })).rejects.toThrow(
"Cannot safely edit metadata because existing EXIF data is invalid",
);
expect(withExif).not.toHaveBeenCalled();
});
it("does not change image dimensions or format", async () => {
@@ -211,10 +255,11 @@ describe("editMetadata", () => {
});
describe("stripMetadata", () => {
it("sanity check: the rich fixture carries both EXIF and ICC", async () => {
it("sanity check: the rich fixture carries EXIF, ICC, and XMP", async () => {
const meta = await sharp(richBuffer).metadata();
expect(meta.exif).toBeTruthy();
expect(meta.icc).toBeTruthy();
expect(meta.xmp?.toString()).toContain("SnapOtter XMP");
});
it("strips both EXIF and ICC when stripAll is true", async () => {
@@ -270,25 +315,34 @@ describe("stripMetadata", () => {
expect(hasIcc).toBe(true);
});
it("keeps EXIF but strips ICC when only stripIcc is true", async () => {
const { hasExif, hasIcc } = await readBack(
it("keeps EXIF and XMP but strips ICC when only stripIcc is true", async () => {
const { hasExif, hasIcc, hasXmp } = await readBack(
await stripMetadata(richImage(), { stripIcc: true }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(false);
expect(hasXmp).toBe(true);
});
it("keeps both EXIF and ICC when only stripXmp is true (selective mode)", async () => {
// XMP has no keepXmp(); it is always stripped in selective mode. EXIF and ICC
// are both preserved because neither of their guards trips.
const { hasExif, hasIcc, image } = await readBack(
const { hasExif, hasIcc, hasXmp, image } = await readBack(
await stripMetadata(richImage(), { stripXmp: true }),
);
expect(hasExif).toBe(true);
expect(hasIcc).toBe(true);
expect(hasXmp).toBe(false);
expect(image.Artist).toBe("OrigArtist");
});
it("preserves XMP when selectively stripping EXIF", async () => {
const { hasExif, hasIcc, hasXmp } = await readBack(
await stripMetadata(richImage(), { stripExif: true, stripXmp: false }),
);
expect(hasExif).toBe(false);
expect(hasIcc).toBe(true);
expect(hasXmp).toBe(true);
});
it("strips both EXIF and ICC when stripExif and stripIcc are both true", async () => {
const { hasExif, hasIcc } = await readBack(
await stripMetadata(richImage(), { stripExif: true, stripIcc: true }),
+16 -16
View File
@@ -242,23 +242,23 @@ describe("compress", () => {
});
describe("sharp format compatibility", () => {
it.each(SHARP_FORMAT_CASES)("convert accepts $sharpFormat", async ({
operationFormat,
expectedFormat,
}) => {
const result = await convert(tinyTestImage(), { format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
});
it.each(SHARP_FORMAT_CASES)(
"convert accepts $sharpFormat",
async ({ operationFormat, expectedFormat }) => {
const result = await convert(tinyTestImage(), { format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
},
);
it.each(SHARP_FORMAT_CASES)("compress accepts $sharpFormat", async ({
operationFormat,
expectedFormat,
}) => {
const result = await compress(tinyTestImage(), { quality: 80, format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
});
it.each(SHARP_FORMAT_CASES)(
"compress accepts $sharpFormat",
async ({ operationFormat, expectedFormat }) => {
const result = await compress(tinyTestImage(), { quality: 80, format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
},
);
});
describe("grayscale", () => {
+324
View File
@@ -37,6 +37,19 @@ function tailMarker(encoded: Uint8Array): number[] {
return Array.from(encoded.slice(encoded.length - END_MARKER.length));
}
function qoiFile(width: number, height: number, data: number[], colorspace = 0): Uint8Array {
const out = new Uint8Array(HEADER_SIZE + data.length + END_MARKER.length);
out.set([0x71, 0x6f, 0x69, 0x66]);
const view = new DataView(out.buffer);
view.setUint32(4, width);
view.setUint32(8, height);
out[12] = 4;
out[13] = colorspace;
out.set(data, HEADER_SIZE);
out.set(END_MARKER, HEADER_SIZE + data.length);
return out;
}
describe("qoiEncode header", () => {
it("writes the qoif magic as the first four bytes", () => {
const out = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
@@ -155,6 +168,11 @@ describe("qoiEncode run-length encoding", () => {
expect(dataBytes(out)).toEqual([QOI_OP_RGB, 255, 0, 0, QOI_OP_RUN | 61, QOI_OP_RUN | 36]);
});
it("flushes a pending run before encoding the next distinct pixel", () => {
const out = qoiEncode(rgba([255, 0, 0, 255], [255, 0, 0, 255], [0, 255, 0, 255]), 3, 1, 4);
expect(dataBytes(out)).toEqual([QOI_OP_RGB, 255, 0, 0, QOI_OP_RUN, QOI_OP_RGB, 0, 255, 0]);
});
it("encodes an all-black-opaque image as a single run (matches the initial pixel)", () => {
// (0,0,0,255) equals the encoder's starting prev, so all 5 px are one run.
const out = qoiEncode(
@@ -169,6 +187,10 @@ describe("qoiEncode run-length encoding", () => {
});
describe("qoiDecode header parsing", () => {
it("rejects a truncated header before reading through the buffer", () => {
expect(() => qoiDecode(new Uint8Array(13))).toThrow("QOI file is too short");
});
it("reads width, height, channels and colorspace back from the header", () => {
const out = qoiEncode(new Uint8Array(6 * 4), 3, 2, 4);
const { header } = qoiDecode(out);
@@ -196,6 +218,308 @@ describe("qoiDecode header parsing", () => {
bad[12] = 2;
expect(() => qoiDecode(bad)).toThrow("Invalid QOI channels");
});
it("throws on an invalid colorspace", () => {
const bad = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
bad[13] = 2;
expect(() => qoiDecode(bad)).toThrow("Invalid QOI colorspace");
});
it("accepts linear colorspace 1", () => {
const linear = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
linear[13] = 1;
expect(qoiDecode(linear).header.colorspace).toBe(1);
});
it("rejects dimensions whose decoded allocation exceeds the safety limit", () => {
const bad = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
const view = new DataView(bad.buffer, bad.byteOffset, bad.byteLength);
view.setUint32(4, 8192);
view.setUint32(8, 8193);
expect(() => qoiDecode(bad)).toThrow("QOI image exceeds the pixel safety limit");
});
it("accepts the exact pixel safety limit before rejecting its missing payload", () => {
const headerOnly = qoiFile(8192, 8192, []);
expect(() => qoiDecode(headerOnly)).toThrow(
"QOI pixel data is too short for declared dimensions",
);
});
it("preflights short payloads near the maximum run-density boundary", () => {
const headerOnly = qoiFile(62, 28, []);
expect(() => qoiDecode(headerOnly)).toThrow(
"QOI pixel data is too short for declared dimensions",
);
});
});
describe("qoiDecode corruption handling", () => {
it("rejects a truncated RGB chunk instead of decoding missing bytes as zero", () => {
const encoded = qoiEncode(rgba([255, 0, 0, 255]), 1, 1, 4);
const truncated = new Uint8Array([
...encoded.slice(0, HEADER_SIZE + 2),
...encoded.slice(encoded.length - END_MARKER.length),
]);
expect(() => qoiDecode(truncated)).toThrow("Truncated QOI pixel data");
});
it("rejects a truncated LUMA chunk", () => {
const encoded = qoiEncode(rgba([16, 20, 24, 255]), 1, 1, 4);
const truncated = new Uint8Array([
...encoded.slice(0, HEADER_SIZE + 1),
...encoded.slice(encoded.length - END_MARKER.length),
]);
expect(() => qoiDecode(truncated)).toThrow("Truncated QOI pixel data");
});
it("rejects a corrupt end marker", () => {
const bad = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
bad[bad.length - 1] = 0;
expect(() => qoiDecode(bad)).toThrow("Invalid QOI end marker");
});
it("rejects a run that exceeds the declared pixel count", () => {
const bad = qoiEncode(rgba([0, 0, 0, 255]), 1, 1, 4);
bad[HEADER_SIZE] = QOI_OP_RUN | 1;
expect(() => qoiDecode(bad)).toThrow("QOI run exceeds the declared pixel count");
});
it("rejects an oversized run after one or more pixels were already decoded", () => {
const bad = qoiFile(2, 1, [QOI_OP_RGB, 255, 0, 0, QOI_OP_RUN | 1]);
expect(() => qoiDecode(bad)).toThrow("QOI run exceeds the declared pixel count");
});
it("updates the color index after a run so a later INDEX chunk is lossless", () => {
const blackHash = refHash(0, 0, 0, 255);
const encoded = qoiFile(3, 1, [QOI_OP_RUN, QOI_OP_RGB, 255, 0, 0, QOI_OP_INDEX | blackHash]);
expect(Array.from(qoiDecode(encoded).pixels)).toEqual([
0, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255,
]);
});
it("rejects unused pixel data before the end marker", () => {
const encoded = qoiEncode(rgba([1, 2, 3, 255]), 1, 1, 4);
const withTrailingChunk = new Uint8Array(encoded.length + 1);
withTrailingChunk.set(encoded.slice(0, -END_MARKER.length));
withTrailingChunk[encoded.length - END_MARKER.length] = QOI_OP_RUN;
withTrailingChunk.set(END_MARKER, encoded.length - END_MARKER.length + 1);
expect(() => qoiDecode(withTrailingChunk)).toThrow("Unexpected QOI pixel data");
});
});
describe("qoiEncode input validation", () => {
it.each([
[0, 1],
[1, 0],
[-1, 1],
[1.5, 1],
[Number.NaN, 1],
[Number.POSITIVE_INFINITY, 1],
])("rejects invalid dimensions %s x %s", (width, height) => {
expect(() => qoiEncode(new Uint8Array(), width, height, 4)).toThrow("Invalid QOI dimensions");
});
it("rejects dimensions whose encoded allocation exceeds the safety limit", () => {
expect(() => qoiEncode(new Uint8Array(), 8192, 8193, 4)).toThrow(
"QOI image exceeds the pixel safety limit",
);
});
it("accepts the exact pixel safety limit before checking buffer length", () => {
expect(() => qoiEncode(new Uint8Array(), 8192, 8192, 4)).toThrow(
"QOI pixel buffer length does not match dimensions and channels",
);
});
it("rejects a runtime-invalid channel count", () => {
expect(() => qoiEncode(new Uint8Array(4), 1, 1, 2 as 3 | 4)).toThrow("Invalid QOI channels");
});
it("requires the exact pixel-buffer length", () => {
expect(() => qoiEncode(new Uint8Array(3), 1, 1, 4)).toThrow(
"QOI pixel buffer length does not match dimensions and channels",
);
expect(() => qoiEncode(new Uint8Array(5), 1, 1, 4)).toThrow(
"QOI pixel buffer length does not match dimensions and channels",
);
});
});
describe("qoiEncode chunk boundaries", () => {
function secondChunkTag(
first: [number, number, number, number],
second: [number, number, number, number],
): number {
const data = dataBytes(qoiEncode(rgba(first, second), 2, 1, 4));
// The first color is deliberately outside DIFF/LUMA and therefore occupies
// a four-byte RGB chunk. The next byte starts the boundary under test.
expect(data[0]).toBe(QOI_OP_RGB);
return data[4];
}
it.each([
[
[100, 100, 100, 255],
[98, 100, 100, 255],
],
[
[100, 100, 100, 255],
[101, 100, 100, 255],
],
[
[100, 100, 100, 255],
[100, 98, 100, 255],
],
[
[100, 100, 100, 255],
[100, 101, 100, 255],
],
[
[100, 100, 100, 255],
[100, 100, 98, 255],
],
[
[100, 100, 100, 255],
[100, 100, 101, 255],
],
] as Array<[[number, number, number, number], [number, number, number, number]]>)(
"uses DIFF at every inclusive boundary for %j -> %j",
(first, second) => {
expect(secondChunkTag(first, second) & 0xc0).toBe(QOI_OP_DIFF);
const input = rgba(first, second);
expect(Array.from(qoiDecode(qoiEncode(input, 2, 1, 4)).pixels)).toEqual(Array.from(input));
},
);
it.each([
[
[100, 100, 100, 255],
[97, 100, 100, 255],
],
[
[100, 100, 100, 255],
[102, 100, 100, 255],
],
[
[100, 100, 100, 255],
[100, 97, 100, 255],
],
[
[100, 100, 100, 255],
[100, 102, 100, 255],
],
[
[100, 100, 100, 255],
[100, 100, 97, 255],
],
[
[100, 100, 100, 255],
[100, 100, 102, 255],
],
] as Array<[[number, number, number, number], [number, number, number, number]]>)(
"does not use DIFF immediately outside its range for %j -> %j",
(first, second) => {
expect(secondChunkTag(first, second) & 0xc0).toBe(QOI_OP_LUMA);
const input = rgba(first, second);
expect(Array.from(qoiDecode(qoiEncode(input, 2, 1, 4)).pixels)).toEqual(Array.from(input));
},
);
it.each([
[
[100, 100, 100, 255],
[68, 68, 68, 255],
],
[
[100, 100, 100, 255],
[131, 131, 131, 255],
],
[
[100, 100, 100, 255],
[102, 110, 110, 255],
],
[
[100, 100, 100, 255],
[117, 110, 110, 255],
],
[
[100, 100, 100, 255],
[110, 110, 102, 255],
],
[
[100, 100, 100, 255],
[110, 110, 117, 255],
],
] as Array<[[number, number, number, number], [number, number, number, number]]>)(
"uses LUMA at every inclusive boundary for %j -> %j",
(first, second) => {
expect(secondChunkTag(first, second) & 0xc0).toBe(QOI_OP_LUMA);
const input = rgba(first, second);
expect(Array.from(qoiDecode(qoiEncode(input, 2, 1, 4)).pixels)).toEqual(Array.from(input));
},
);
it.each([
[
[100, 100, 100, 255],
[67, 67, 67, 255],
],
[
[100, 100, 100, 255],
[132, 132, 132, 255],
],
[
[100, 100, 100, 255],
[101, 110, 110, 255],
],
[
[100, 100, 100, 255],
[118, 110, 110, 255],
],
[
[100, 100, 100, 255],
[110, 110, 101, 255],
],
[
[100, 100, 100, 255],
[110, 110, 118, 255],
],
] as Array<[[number, number, number, number], [number, number, number, number]]>)(
"does not use LUMA immediately outside its range for %j -> %j",
(first, second) => {
expect(secondChunkTag(first, second)).toBe(QOI_OP_RGB);
},
);
});
describe("qoiEncode index collision safety", () => {
it.each([
[
[10, 20, 30, 100],
[10, 20, 30, 164],
],
[
[10, 20, 30, 100],
[10, 20, 94, 100],
],
[
[10, 20, 30, 100],
[10, 84, 30, 100],
],
[
[10, 20, 30, 100],
[74, 20, 30, 100],
],
] as Array<[[number, number, number, number], [number, number, number, number]]>)(
"does not emit an index hit when only part of a hash-colliding pixel matches",
(first, colliding) => {
expect(refHash(...first)).toBe(refHash(...colliding));
const separator: [number, number, number, number] = [200, 201, 202, 203];
const input = rgba(first, separator, colliding);
expect(Array.from(qoiDecode(qoiEncode(input, 3, 1, 4)).pixels)).toEqual(Array.from(input));
},
);
});
describe("qoi round-trip (encode then decode restores the exact RGBA pixels)", () => {
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resize } from "../src/operations/resize.js";
import type { Sharp } from "../src/types.js";
@@ -80,15 +80,222 @@ describe("resize positive-dimension guards (L18 width, L21 height)", () => {
"Resize height must be greater than 0",
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional width (%s) before Sharp",
async (width) => {
await expect(resize(source(100, 50), { width })).rejects.toThrow(
"Resize width must be a positive integer",
);
},
);
it.each([Number.NaN, Number.NEGATIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional height (%s) before Sharp",
async (height) => {
await expect(resize(source(100, 50), { height })).rejects.toThrow(
"Resize height must be a positive integer",
);
},
);
});
// The clamp block at L28-L35 mutates width/height BEFORE handing them to
// Sharp, and Sharp also receives withoutEnlargement. Under fit "cover"/"fill"
// Sharp's own withoutEnlargement clamps identically, which masks the manual
// block (mutants there survive). Under fit "contain", Sharp's withoutEnlargement
// does NOT shrink to fit (it pads to the full box), so ONLY the manual clamp
// changes the dimensions. Using "contain" makes the block observable, so the
// L28/L33/L34 mutants die on an exact-dimension mismatch.
describe("resize percentage safety boundaries", () => {
it("rejects zero and negative percentages at the public guard", async () => {
await expect(resize(source(100, 50), { percentage: 0 })).rejects.toThrow(
"Resize percentage must be greater than 0",
);
await expect(resize(source(100, 50), { percentage: -1 })).rejects.toThrow(
"Resize percentage must be greater than 0",
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY])(
"rejects a non-finite percentage (%s)",
async (percentage) => {
await expect(resize(source(100, 50), { percentage })).rejects.toThrow(
"Resize percentage must be finite",
);
},
);
it("accepts the exact maximum percentage and rejects one step above it", async () => {
const dims = await outputDims(await resize(source(10, 5), { percentage: 1000 }));
expect(dims).toEqual({ width: 100, height: 50 });
await expect(resize(source(10, 5), { percentage: 1001 })).rejects.toThrow(
"Resize percentage must not exceed 1000",
);
});
});
describe("resize output allocation safety boundaries", () => {
function metadataOnlyImage(
width = 100,
height = 100,
): {
image: Sharp;
resizeSpy: ReturnType<typeof vi.fn>;
} {
const resizeSpy = vi.fn();
const fake = {
metadata: vi.fn().mockResolvedValue({ width, height }),
resize: resizeSpy,
};
resizeSpy.mockReturnValue(fake);
return { image: fake as unknown as Sharp, resizeSpy };
}
it("accepts 16383 pixels on an axis and rejects 16384", async () => {
const allowed = metadataOnlyImage();
await expect(resize(allowed.image, { width: 16383, height: 1, fit: "fill" })).resolves.toBe(
allowed.image,
);
expect(allowed.resizeSpy).toHaveBeenCalledOnce();
const rejected = metadataOnlyImage();
await expect(resize(rejected.image, { width: 16384, height: 1, fit: "fill" })).rejects.toThrow(
"Resize output must not exceed 16383 pixels on either side",
);
expect(rejected.resizeSpy).not.toHaveBeenCalled();
});
it("accepts exactly 67,108,864 pixels and rejects one row more", async () => {
const allowed = metadataOnlyImage();
await expect(resize(allowed.image, { width: 8192, height: 8192, fit: "fill" })).resolves.toBe(
allowed.image,
);
expect(allowed.resizeSpy).toHaveBeenCalledOnce();
const rejected = metadataOnlyImage();
await expect(
resize(rejected.image, { width: 8192, height: 8193, fit: "fill" }),
).rejects.toThrow("Resize output must not exceed 67108864 total pixels");
expect(rejected.resizeSpy).not.toHaveBeenCalled();
});
it("checks each axis independently", async () => {
const allowedHeight = metadataOnlyImage();
await expect(
resize(allowedHeight.image, { width: 1, height: 16383, fit: "fill" }),
).resolves.toBe(allowedHeight.image);
expect(allowedHeight.resizeSpy).toHaveBeenCalledOnce();
await expect(
resize(metadataOnlyImage().image, { width: 1, height: 16384, fit: "fill" }),
).rejects.toThrow("Resize output must not exceed 16383 pixels on either side");
});
it("applies aspect-ratio math to inside and outside before enforcing limits", async () => {
const inside = metadataOnlyImage(2, 1);
await expect(resize(inside.image, { width: 8192, height: 8192, fit: "inside" })).resolves.toBe(
inside.image,
);
expect(inside.resizeSpy).toHaveBeenCalledOnce();
const outside = metadataOnlyImage(2, 1);
await expect(
resize(outside.image, { width: 8192, height: 8192, fit: "outside" }),
).rejects.toThrow("Resize output must not exceed 16383 pixels on either side");
expect(outside.resizeSpy).not.toHaveBeenCalled();
const tallInside = metadataOnlyImage(1, 4);
await expect(
resize(tallInside.image, { width: 4096, height: 16383, fit: "inside" }),
).resolves.toBe(tallInside.image);
expect(tallInside.resizeSpy).toHaveBeenCalledOnce();
const heightLimitedInside = metadataOnlyImage(1, 2);
await expect(
resize(heightLimitedInside.image, { width: 8192, height: 16384, fit: "inside" }),
).rejects.toThrow("Resize output must not exceed 16383 pixels on either side");
expect(heightLimitedInside.resizeSpy).not.toHaveBeenCalled();
const narrowInside = metadataOnlyImage(100, 1);
await expect(
resize(narrowInside.image, { width: 16383, height: 16384, fit: "inside" }),
).resolves.toBe(narrowInside.image);
expect(narrowInside.resizeSpy).toHaveBeenCalledOnce();
});
it("enforces proportional limits for width-only and height-only requests", async () => {
const widthOnly = metadataOnlyImage(1, 2);
await expect(resize(widthOnly.image, { width: 8192 })).rejects.toThrow(
"Resize output must not exceed 16383 pixels on either side",
);
expect(widthOnly.resizeSpy).not.toHaveBeenCalled();
const widthAxisOnly = metadataOnlyImage(2, 1);
await expect(resize(widthAxisOnly.image, { width: 16384 })).rejects.toThrow(
"Resize output must not exceed 16383 pixels on either side",
);
expect(widthAxisOnly.resizeSpy).not.toHaveBeenCalled();
const heightOnly = metadataOnlyImage(2, 1);
await expect(resize(heightOnly.image, { height: 8192 })).rejects.toThrow(
"Resize output must not exceed 16383 pixels on either side",
);
expect(heightOnly.resizeSpy).not.toHaveBeenCalled();
const heightAxisOnly = metadataOnlyImage(1, 2);
await expect(resize(heightAxisOnly.image, { height: 16384 })).rejects.toThrow(
"Resize output must not exceed 16383 pixels on either side",
);
expect(heightAxisOnly.resizeSpy).not.toHaveBeenCalled();
});
});
describe("resize required-input and metadata guards", () => {
it("rejects a request with no dimensions or percentage", async () => {
await expect(resize(source(100, 50), {})).rejects.toThrow(
"Resize requires width, height, or percentage",
);
});
it("rejects metadata missing either source dimension", async () => {
const missingWidth = {
metadata: vi.fn().mockResolvedValue({ height: 100 }),
resize: vi.fn(),
} as unknown as Sharp;
await expect(resize(missingWidth, { width: 10 })).rejects.toThrow(
"Cannot determine image dimensions for resize",
);
const missingHeight = {
metadata: vi.fn().mockResolvedValue({ width: 100 }),
resize: vi.fn(),
} as unknown as Sharp;
await expect(resize(missingHeight, { height: 10 })).rejects.toThrow(
"Cannot determine image dimensions for resize",
);
});
});
describe("resize output geometry safety", () => {
it("computes contain and outside boxes from the source aspect ratio", async () => {
expect(
await outputDims(await resize(source(100, 50), { width: 80, height: 80, fit: "inside" })),
).toEqual({ width: 80, height: 40 });
expect(
await outputDims(await resize(source(100, 50), { width: 80, height: 80, fit: "outside" })),
).toEqual({ width: 160, height: 80 });
});
it("preserves aspect ratio for width-only and height-only resize", async () => {
expect(await outputDims(await resize(source(100, 50), { width: 40 }))).toEqual({
width: 40,
height: 20,
});
expect(await outputDims(await resize(source(100, 50), { height: 20 }))).toEqual({
width: 40,
height: 20,
});
});
});
// The clamp block mutates width/height before handing them to Sharp. This is
// especially important for fit "contain": Sharp otherwise pads to the full
// requested box even when its own withoutEnlargement option is true. Using
// "contain" makes the manual clamp observable through exact output dimensions.
describe("resize withoutEnlargement block execution (L28)", () => {
it("keeps output at source size when target is larger and withoutEnlargement is true", async () => {
// Manual clamp -> 100x50; a false-mutant on L28 skips it and (under contain)