check / check (push) Successful in 42s
The download idle deadline's timer is unref'd so it never holds the process open, and a test checks no timer is left after a download completes or fails. A test covers the rejection of "#" in a request path. The EXIF scan accepts an APP1 segment only when its length is at least 8, since a shorter one cannot hold the six-byte Exif header; tests cover lengths 7 and 8. Model: opus-5-5
137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
/**
|
|
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
|
|
*
|
|
* The originals come from users' libraries, so a truncated or corrupt JPEG
|
|
* must neither hang the scan nor throw out of it, and a malformed file must be
|
|
* told apart from one that simply has no EXIF: the record carries the reason in
|
|
* `exifError`. Each input below is a short hand-built byte array.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
extractExifFromJpeg,
|
|
extractImageMetadata,
|
|
} from "../../src/metadata-backup.js";
|
|
|
|
const SOI = [0xff, 0xd8]; // start of image
|
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
|
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
|
|
|
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
|
const TIFF_ORIENTATION_6 = [
|
|
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12,
|
|
0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00,
|
|
];
|
|
|
|
// An APP1 segment whose length field matches its data.
|
|
const app1 = (data: number[]): number[] => {
|
|
const len = data.length + 2;
|
|
return [0xff, 0xe1, len >> 8, len & 0xff, ...data];
|
|
};
|
|
|
|
const bytes = (...parts: number[][]): Uint8Array =>
|
|
new Uint8Array(parts.flat());
|
|
|
|
describe("extractExifFromJpeg", () => {
|
|
it("returns the EXIF segment of a valid JPEG", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
|
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
|
|
expect(scan.error).toBeUndefined();
|
|
expect([...scan.exif!]).toEqual(data);
|
|
});
|
|
|
|
it("returns nothing for a file that is not a JPEG", () => {
|
|
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
expect(extractExifFromJpeg(png)).toEqual({});
|
|
});
|
|
|
|
it("returns nothing for a JPEG without EXIF", () => {
|
|
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
|
|
});
|
|
|
|
it("ignores an APP1 segment too short to hold the Exif header", () => {
|
|
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
|
|
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
|
|
// which the old code, lacking the length check, returned as EXIF.
|
|
const short = app1(EXIF_HEADER.slice(0, 5));
|
|
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
|
|
});
|
|
|
|
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
|
|
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
|
|
expect(scan.error).toBeUndefined();
|
|
expect([...scan.exif!]).toEqual(EXIF_HEADER);
|
|
});
|
|
|
|
it("reports a JPEG truncated inside a segment header", () => {
|
|
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
|
|
expect(scan.exif).toBeUndefined();
|
|
expect(scan.error).toMatch(/truncated segment length/);
|
|
});
|
|
|
|
it("reports a JPEG that ends before the image data", () => {
|
|
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
const scan = extractExifFromJpeg(bytes(SOI, app0));
|
|
expect(scan.error).toMatch(/ends before the image data/);
|
|
});
|
|
|
|
it("stops on a zero-length segment instead of looping", () => {
|
|
// A length of 0 would otherwise step the scan by 2 bytes at a time
|
|
// through the rest of the file, reading garbage as markers.
|
|
const zero = [0xff, 0xe0, 0x00, 0x00];
|
|
const scan = extractExifFromJpeg(
|
|
bytes(SOI, zero, zero, zero, zero, SOS),
|
|
);
|
|
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
|
|
});
|
|
|
|
it("stops on a segment length of 1", () => {
|
|
const scan = extractExifFromJpeg(
|
|
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
|
|
);
|
|
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
|
|
});
|
|
|
|
it("reports a segment length that runs past the end of the file", () => {
|
|
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
|
const scan = extractExifFromJpeg(
|
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
|
);
|
|
expect(scan.exif).toBeUndefined();
|
|
expect(scan.error).toMatch(/runs past the end of the file/);
|
|
});
|
|
});
|
|
|
|
describe("extractImageMetadata", () => {
|
|
it("parses EXIF from a valid JPEG", () => {
|
|
const meta = extractImageMetadata(
|
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
|
);
|
|
expect(meta?.exifError).toBeUndefined();
|
|
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
|
|
});
|
|
|
|
it("returns nothing for a file that is not a JPEG", () => {
|
|
const text = new TextEncoder().encode("just some text, not an image");
|
|
expect(extractImageMetadata(text)).toBeUndefined();
|
|
});
|
|
|
|
it("records the reason when the JPEG is malformed", () => {
|
|
const meta = extractImageMetadata(
|
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
|
);
|
|
expect(meta?.exif).toBeUndefined();
|
|
expect(meta?.exifError).toMatch(/runs past the end of the file/);
|
|
});
|
|
|
|
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
|
const data = [...EXIF_HEADER, 0x58, 0x58];
|
|
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
|
|
expect(meta?.exif).toBeUndefined();
|
|
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
|
|
expect(meta?.exifError).toEqual(expect.any(String));
|
|
});
|
|
});
|