check / check (push) Successful in 29s
The segment scan behind `backup-metadata --exif` now checks every segment length against the bytes that remain and stops on lengths under 2, so a truncated or corrupt original can neither throw nor loop. A malformed or unparseable EXIF segment is recorded as `imageMetadata.exifError`, and a failure to read the original as `imageMetadataError` in the per-file JSON, instead of the field being silently left out. Tests use short hand-built byte arrays. Model: opus-5-5
123 lines
4.8 KiB
TypeScript
123 lines
4.8 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("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));
|
|
});
|
|
});
|