Files
client/tests/blob-sanitize-test.html
T
Laan Tungir d1f537fcae Phase 1: client-side metadata stripping
Add blob-sanitize.mjs with stripBlobMetadata() (canvas re-encode for
JPEG/PNG/WebP, byte-level PDF /Author stripper, pass-through for audio/video)
and hasImageMetadata() (lightweight EXIF/XMP detection). Wire into
uploadToServer() before calculateSHA256 so the client-signed hash commits
to cleaned bytes (preserves BUD-01 content addressing). Add skipSanitize
opt-out for mirror/verbatim uploads. On 415 + X-Reason: exif_detected,
retry once with forced stricter re-encode. Update post-composer status
text and surface metadata-rejection errors.

See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
2026-07-31 07:25:56 -04:00

269 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>blob-sanitize.mjs tests</title>
<style>
body { font: 14px/1.5 monospace; background: #0d1117; color: #c9d1d9; padding: 24px; }
h1 { font-size: 18px; }
.pass { color: #3fb950; }
.fail { color: #f85149; }
.summary { font-size: 16px; font-weight: bold; margin-top: 16px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; }
pre { background: #161b22; padding: 8px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<h1>blob-sanitize.mjs — client-side stripping tests</h1>
<p>Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).</p>
<pre id="out"></pre>
<div class="summary" id="summary"></div>
<script type="module">
import { stripBlobMetadata, hasImageMetadata } from '../www/js/blob-sanitize.mjs';
const out = document.getElementById('out');
const summaryEl = document.getElementById('summary');
let pass = 0, fail = 0;
function log(msg) {
out.textContent += msg + '\n';
}
function ok(name) { pass++; log(` ✅ PASS: ${name}`); }
function bad(name, detail) { fail++; log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`); }
// ─── helpers ─────────────────────────────────────────────────────────────
// SHA-256 of a File/Blob, returns hex.
async function sha256(file) {
const buf = await file.arrayBuffer();
const h = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(h)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
// Read file bytes as Uint8Array.
async function bytes(file) {
return new Uint8Array(await file.arrayBuffer());
}
// Check if a JPEG contains an Exif\0\0 marker.
function jpegHasExif(arr) {
if (arr.length < 4 || arr[0] !== 0xff || arr[1] !== 0xd8) return false;
let i = 2;
while (i + 4 < arr.length) {
if (arr[i] !== 0xff) break;
const marker = arr[i + 1];
if (marker === 0xda) break;
if (marker === 0xe1) {
if (i + 10 <= arr.length) {
const head = arr.slice(i + 4, i + 10);
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) return true;
}
const segLen = (arr[i + 2] << 8) | arr[i + 3];
i += 2 + segLen;
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
i += 2;
} else {
const segLen = (arr[i + 2] << 8) | arr[i + 3];
i += 2 + segLen;
}
}
return false;
}
// Check if a PNG has an eXIf chunk.
function pngHasExif(arr) {
if (arr.length < 8) return false;
let i = 8;
while (i + 8 < arr.length) {
const len = (arr[i] << 24) | (arr[i + 1] << 16) | (arr[i + 2] << 8) | arr[i + 3];
const t = new TextDecoder().decode(arr.slice(i + 4, i + 8));
if (t === 'eXIf') return true;
if (t === 'IEND') break;
i += 8 + len + 4;
}
return false;
}
// Create a small test JPEG with EXIF using canvas + manual APP1 injection.
async function makeExifJpeg() {
// First make a clean 2x2 JPEG via canvas.
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const cleanBlob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
const cleanBytes = new Uint8Array(await cleanBlob.arrayBuffer());
// Build an APP1 Exif segment: FF E1 <len> Exif\0\0 <minimal TIFF header>
const exifPayload = new Uint8Array([
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // Exif\0\0
0x49, 0x49, 0x2a, 0x00, // little-endian TIFF header
0x08, 0x00, 0x00, 0x00, // offset to IFD0
0x00, 0x00, // 0 entries
0x00, 0x00, 0x00, 0x00 // next IFD = 0
]);
const segLen = exifPayload.length + 2;
const app1 = new Uint8Array([0xff, 0xe1, (segLen >> 8) & 0xff, segLen & 0xff, ...exifPayload]);
// Insert APP1 right after FF D8 (before the existing markers).
const out = new Uint8Array(cleanBytes.length + app1.length);
out.set(cleanBytes.slice(0, 2), 0); // FF D8
out.set(app1, 2); // APP1 Exif
out.set(cleanBytes.slice(2), 2 + app1.length); // rest
return new File([out], 'exif.jpg', { type: 'image/jpeg' });
}
// Create a clean JPEG via canvas.
async function makeCleanJpeg() {
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
return new File([blob], 'clean.jpg', { type: 'image/jpeg' });
}
// Create a PNG with an eXIf chunk.
async function makeExifPng() {
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
const cleanBytes = new Uint8Array(await blob.arrayBuffer());
// Build an eXIf chunk: <len:4> eXIf <data> <crc:4>
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
const chunk = new Uint8Array(4 + 4 + exifData.length + 4);
const dv = new DataView(chunk.buffer);
dv.setUint32(0, exifData.length); // length
chunk[4] = 0x65; chunk[5] = 0x58; chunk[6] = 0x49; chunk[7] = 0x66; // "eXIf"
chunk.set(exifData, 8);
// CRC left as 0 — browsers don't validate on encode, and we only check the output.
// Insert before IEND. Find IEND (49 45 4E 44) in cleanBytes.
let iendOff = -1;
for (let i = cleanBytes.length - 12; i >= 8; i--) {
if (cleanBytes[i] === 0x49 && cleanBytes[i + 1] === 0x45 &&
cleanBytes[i + 2] === 0x4e && cleanBytes[i + 3] === 0x44) {
// IEND chunk starts 4 bytes before the type (at the length field)
iendOff = i - 4;
break;
}
}
if (iendOff < 0) return new File([cleanBytes], 'exif.png', { type: 'image/png' });
const out = new Uint8Array(cleanBytes.length + chunk.length);
out.set(cleanBytes.slice(0, iendOff), 0);
out.set(chunk, iendOff);
out.set(cleanBytes.slice(iendOff), iendOff + chunk.length);
return new File([out], 'exif.png', { type: 'image/png' });
}
// ─── tests ───────────────────────────────────────────────────────────────
async function run() {
log('=== blob-sanitize.mjs tests ===\n');
// Test 1: EXIF JPEG → stripped, no Exif marker, hash differs.
log('[JPEG EXIF stripping]');
{
const exifFile = await makeExifJpeg();
const stripped = await stripBlobMetadata(exifFile);
const outBytes = await bytes(stripped);
if (!jpegHasExif(outBytes)) ok('EXIF JPEG: no Exif marker after stripping');
else bad('EXIF JPEG: no Exif marker after stripping');
const origHash = await sha256(exifFile);
const newHash = await sha256(stripped);
if (origHash !== newHash) ok('EXIF JPEG: hash differs (re-encode happened)');
else bad('EXIF JPEG: hash differs (re-encode happened)');
if (stripped.type === 'image/jpeg') ok('EXIF JPEG: output type is image/jpeg');
else bad('EXIF JPEG: output type is image/jpeg', `got ${stripped.type}`);
}
// Test 2: clean JPEG → still re-encoded (canvas always re-encodes).
log('[clean JPEG]');
{
const clean = await makeCleanJpeg();
const stripped = await stripBlobMetadata(clean);
const outBytes = await bytes(stripped);
if (!jpegHasExif(outBytes)) ok('clean JPEG: no Exif marker');
else bad('clean JPEG: no Exif marker');
}
// Test 3: PNG with eXIf → stripped, no eXIf chunk.
log('[PNG eXIf stripping]');
{
const exifPng = await makeExifPng();
const stripped = await stripBlobMetadata(exifPng);
const outBytes = await bytes(stripped);
if (!pngHasExif(outBytes)) ok('PNG eXIf: no eXIf chunk after stripping');
else bad('PNG eXIf: no eXIf chunk after stripping');
const origHash = await sha256(exifPng);
const newHash = await sha256(stripped);
if (origHash !== newHash) ok('PNG eXIf: hash differs');
else bad('PNG eXIf: hash differs');
}
// Test 4: hasImageMetadata detects EXIF.
log('[hasImageMetadata]');
{
const exifFile = await makeExifJpeg();
const detected = await hasImageMetadata(exifFile);
if (detected === true) ok('hasImageMetadata: detects EXIF JPEG');
else bad('hasImageMetadata: detects EXIF JPEG', `got ${detected}`);
const clean = await makeCleanJpeg();
const cleanDetected = await hasImageMetadata(clean);
if (cleanDetected === false) ok('hasImageMetadata: clean JPEG → false');
else bad('hasImageMetadata: clean JPEG → false', `got ${cleanDetected}`);
}
// Test 5: pass-through for audio.
log('[pass-through audio]');
{
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
const audioFile = new File([audioBytes], 'test.mp3', { type: 'audio/mpeg' });
const result = await stripBlobMetadata(audioFile);
if (result === audioFile) ok('audio: pass-through (same File object)');
else bad('audio: pass-through (same File object)');
}
// Test 6: PDF with /Author → stripped.
log('[PDF /Author stripping]');
{
const pdfContent = '%PDF-1.4\n/Author (Secret Person)\n/Producer (Test)\n%%EOF';
const pdfFile = new File([pdfContent], 'test.pdf', { type: 'application/pdf' });
const stripped = await stripBlobMetadata(pdfFile);
const outText = new TextDecoder().decode(await bytes(stripped));
if (!outText.includes('Secret Person')) ok('PDF: /Author value removed');
else bad('PDF: /Author value removed');
if (outText.includes('/Author()')) ok('PDF: /Author key blanked to ()');
else bad('PDF: /Author key blanked to ()', `got: ${outText}`);
}
// Test 7: null/undefined input.
log('[edge cases]');
{
const r1 = await stripBlobMetadata(null);
if (r1 === null) ok('null input → null');
else bad('null input → null');
}
// Summary.
log('');
const color = fail === 0 ? '#3fb950' : '#f85149';
summaryEl.innerHTML = `<span style="color:${color}">${pass} passed, ${fail} failed</span>`;
summaryEl.style.borderColor = fail === 0 ? '#3fb950' : '#f85149';
}
run().catch((err) => {
log(`\nFATAL: ${err?.message || err}\n${err?.stack || ''}`);
fail++;
summaryEl.innerHTML = `<span style="color:#f85149">${pass} passed, ${fail} failed</span>`;
summaryEl.style.borderColor = '#f85149';
});
</script>
</body>
</html>