Files
immich-folio/lib/__tests__/metadataStrip.test.ts
Ralksta c3f92ca4ec release: v0.15.0 (#585)
Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
2026-09-19 22:24:10 +02:00

178 lines
6.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
findJpegMetadataRanges,
findIsobmffMetadataRanges,
findMetadataRanges,
stripMetadata,
isStrippable,
} from '../metadataStrip';
// ── kleine Bau-Helfer ───────────────────────────────────────────────────────
const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
const bytes = (...n: number[]) => new Uint8Array(n);
const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** JPEG-Segment: FFxx <len inkl. Längenfeld> <payload> */
const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
/** ISOBMFF-Box mit korrekt berechneter Größe. */
const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
describe('isStrippable', () => {
it('kennt JPEG und die ISOBMFF-Familie', () => {
expect(isStrippable('image/jpeg')).toBe(true);
expect(isStrippable('IMAGE/AVIF')).toBe(true);
expect(isStrippable('image/jpeg; charset=binary')).toBe(true);
expect(isStrippable('image/png')).toBe(false);
expect(isStrippable('application/octet-stream')).toBe(false);
});
});
describe('JPEG', () => {
const exifPayload = cat(ascii('Exif\0\0'), ascii('MM\0*GPS 54.156, 15.3717'));
const iccPayload = cat(ascii('ICC_PROFILE\0'), bytes(1, 1, 2, 3, 4, 5));
const jfifPayload = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
const bildDaten = bytes(0x12, 0x34, 0x56, 0x78, 0x9a);
const bauJpeg = () =>
cat(
bytes(0xff, 0xd8), // SOI
seg(0xe0, jfifPayload), // APP0 JFIF — bleibt
seg(0xe1, exifPayload), // APP1 EXIF — muss weg
seg(0xe2, iccPayload), // APP2 ICC — bleibt (Farbprofil!)
seg(0xed, ascii('Photoshop 3.0\0IPTC')), // APP13 — muss weg
seg(0xfe, ascii('ein Kommentar')), // COM — muss weg
bytes(0xff, 0xda),
be16(8),
bytes(1, 0, 0, 63, 0), // SOS
bildDaten,
bytes(0xff, 0xd9), // EOI
);
it('findet EXIF, IPTC und Kommentar, aber nicht JFIF/ICC', () => {
const b = bauJpeg();
const ranges = findJpegMetadataRanges(b);
expect(ranges).toHaveLength(3);
// Das EXIF-Segment muss vollständig erfasst sein
const exifStart = b.indexOf(0x45); // 'E' von 'Exif'
expect(ranges[0].start).toBe(exifStart);
expect(ranges[0].end - ranges[0].start).toBe(exifPayload.length);
});
it('nullt die GPS-Daten und lässt alles andere unangetastet', () => {
const original = bauJpeg();
const gestrippt = stripMetadata(bauJpeg(), 'image/jpeg')!;
expect(gestrippt).not.toBeNull();
// Länge unverändert -> Content-Length bleibt gültig
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GPS 54.156');
expect(alsText).not.toContain('Exif');
expect(alsText).not.toContain('IPTC');
expect(alsText).not.toContain('ein Kommentar');
// Farbprofil und JFIF müssen erhalten bleiben, sonst kippen die Farben
expect(alsText).toContain('ICC_PROFILE');
expect(alsText).toContain('JFIF');
// Bilddaten hinter SOS Byte für Byte identisch
const sos = original.length - bildDaten.length - 2;
expect(Array.from(gestrippt.slice(sos))).toEqual(Array.from(original.slice(sos)));
});
it('weist etwas zurück, das kein JPEG ist', () => {
expect(() => findJpegMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03))).toThrow();
expect(findMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03), 'image/jpeg')).toBeNull();
});
});
describe('AVIF / ISOBMFF', () => {
const exifNutzlast = ascii('MM\0*GEHEIME-GPS-KOORDINATEN');
const bildNutzlast = ascii('AV1-BILDDATEN-UNANTASTBAR');
/** infe v2: version+flags, item_ID(16), protection(16), item_type(4) */
const infe = (id: number, typ: string) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(typ), bytes(0));
const bauAvif = () => {
// Erst ohne mdat bauen, um die Offsets zu kennen.
const ftyp = box('ftyp', ascii('avif'), be32(0), ascii('avifmif1'));
const iinf = box('iinf', bytes(0, 0, 0, 0), be16(2), infe(1, 'Exif'), infe(2, 'av01'));
// iloc v0: offset_size=4, length_size=4, base_offset_size=0
const ilocEintrag = (id: number, offset: number, len: number) =>
cat(be16(id), be16(0), be16(1), be32(offset), be32(len));
// Platzhalter, um die Gesamtlänge des Headers zu bestimmen
const ilocRoh = (o1: number, o2: number) =>
box(
'iloc',
bytes(0, 0, 0, 0),
bytes(0x44, 0x00),
be16(2),
ilocEintrag(1, o1, exifNutzlast.length),
ilocEintrag(2, o2, bildNutzlast.length),
);
const kopfLaenge = (iloc: Uint8Array) =>
ftyp.length + box('meta', bytes(0, 0, 0, 0), iinf, iloc).length + 8; // +8 mdat-Header
let iloc = ilocRoh(0, 0);
const basis = kopfLaenge(iloc);
iloc = ilocRoh(basis, basis + exifNutzlast.length);
const meta = box('meta', bytes(0, 0, 0, 0), iinf, iloc);
const mdat = box('mdat', exifNutzlast, bildNutzlast);
return cat(ftyp, meta, mdat);
};
it('findet das Exif-Item über iinf und iloc', () => {
const b = bauAvif();
const ranges = findIsobmffMetadataRanges(b);
expect(ranges).toHaveLength(1);
const gefunden = new TextDecoder('latin1').decode(b.slice(ranges[0].start, ranges[0].end));
expect(gefunden).toBe('MM\0*GEHEIME-GPS-KOORDINATEN');
});
it('nullt EXIF, lässt die Bilddaten und die Länge unberührt', () => {
const original = bauAvif();
const gestrippt = stripMetadata(bauAvif(), 'image/avif')!;
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GEHEIME-GPS-KOORDINATEN');
expect(alsText).toContain('AV1-BILDDATEN-UNANTASTBAR');
// Die Container-Struktur muss intakt bleiben
expect(alsText).toContain('ftyp');
expect(alsText).toContain('iloc');
});
it('gibt null zurück, wenn es kein ISOBMFF ist', () => {
expect(findMetadataRanges(ascii('nicht mal ansatzweise'), 'image/avif')).toBeNull();
});
});
describe('unbekannte Formate', () => {
it('liefern null — lieber nicht ausliefern als leaken', () => {
expect(findMetadataRanges(bytes(0x89, 0x50, 0x4e, 0x47), 'image/png')).toBeNull();
expect(stripMetadata(bytes(1, 2, 3), 'application/octet-stream')).toBeNull();
});
});