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).
269 lines
10 KiB
HTML
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>
|