/** * Tests for reading EXIF (`src/exif.ts`) and the image metadata * `quak backup-metadata --exif` records. * * The originals come from users' libraries, so a truncated or corrupt file * must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, * and `backup-metadata` tells an EXIF block it cannot read apart from a file * that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below * is a short hand-built byte array; the HEIC is a real file. */ import { describe, expect, it } from "vitest"; import { readPhotoExif } from "../../src/exif.js"; import { extractImageMetadata } from "../../src/metadata-backup.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; const SOI = [0xff, 0xd8]; // start of image const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00]; // 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, ]; // A big-endian TIFF block holding Orientation 6 and DateTimeOriginal // "0000:00:00 00:00:00", which a camera with an unset clock writes. const TIFF_UNSET_DATE = [ ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 // The first IFD, at 8: two entries, then no next IFD. ...[0x00, 0x02], // Orientation (0x0112), SHORT, 6. ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], // The Exif IFD's offset (0x8769), LONG, 38. ...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26], ...[0x00, 0x00, 0x00, 0x00], // The Exif IFD, at 38: one entry, then no next IFD. ...[0x00, 0x01], // DateTimeOriginal (0x9003), 20 ASCII bytes at 56. ...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38], ...[0x00, 0x00, 0x00, 0x00], ...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56 ]; // A big-endian TIFF block holding a GPSAltitude of 12.5 m and no // GPSAltitudeRef. const TIFF_ALTITUDE_WITHOUT_REF = [ ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 // The first IFD, at 8: one entry, then no next IFD. ...[0x00, 0x01], // The GPS IFD's offset (0x8825), LONG, 26. ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], ...[0x00, 0x00, 0x00, 0x00], // The GPS IFD, at 26: one entry, then no next IFD. ...[0x00, 0x01], // GPSAltitude (0x0006), one RATIONAL at 44. ...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c], ...[0x00, 0x00, 0x00, 0x00], ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 ]; // 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("readPhotoExif", () => { it("reads the common fields of a valid JPEG", () => { const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ orientation: 6, }); }); it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ orientation: 6, }); }); it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ gpsAltitude: 12.5, }); }); it.each([ [ "a file that is not an image", new TextEncoder().encode("just some text, not an image"), ], [ "a PNG without EXIF", bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ], ["a JPEG without EXIF", bytes(SOI, APP0, SOS)], // A length under 8 cannot hold the six-byte "Exif\0\0" header. This one // has length 7 and holds only "Exif\0", so a read past its end would // take the next segment's bytes as EXIF. [ "an APP1 segment too short to hold the Exif header", bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), ], [ "an APP1 segment holding just the Exif header", bytes(SOI, app1(EXIF_HEADER), SOS), ], [ "a JPEG truncated inside a segment header", bytes(SOI, [0xff, 0xe1, 0x00]), ], ["a JPEG that ends before the image data", bytes(SOI, APP0)], // A length of 0 would step a scan by 2 bytes at a time through the // rest of the file, reading garbage as markers. [ "a zero-length segment", bytes( SOI, ZERO_LENGTH_APP0, ZERO_LENGTH_APP0, ZERO_LENGTH_APP0, ZERO_LENGTH_APP0, SOS, ), ], ["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)], // APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows. [ "a segment length that runs past the end of the file", bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), ], // "XX" where the TIFF byte order belongs. [ "an EXIF block that cannot be parsed", bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS), ], ])("returns no fields for %s", (_, input) => { expect(readPhotoExif(input)).toStrictEqual({}); }); }); 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({ Orientation: { value: 6 } }); }); it("parses EXIF from a HEIC", () => { const meta = extractImageMetadata(HEIC_WITH_EXIF); expect(meta?.exifError).toBeUndefined(); expect(meta?.exif).toMatchObject({ Make: { value: ["Canon"] }, Model: { value: ["EOS R5"] }, DateTimeOriginal: { value: ["2021:07:15 14:30:00"] }, Orientation: { value: 6 }, GPSLatitudeRef: { value: ["N"] }, }); }); it("returns nothing for a file that is not an image", () => { 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).toBe( "no tag could be read from the EXIF block", ); }); it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => { // The raw bytes are the whole EXIF block as exifreader finds it: for a // JPEG, the APP1 segment, marker and length included. const segment = app1([...EXIF_HEADER, 0x58, 0x58]); const meta = extractImageMetadata(bytes(SOI, segment, SOS)); expect(meta?.exif).toBeUndefined(); expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64")); expect(meta?.exifError).toBe( "no tag could be read from the EXIF block", ); }); });