diff --git a/README.md b/README.md index 0963d22..2fcb273 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,10 @@ Image Metadata Inspector & Cleaner is available at `/tools/image/metadata/` on t - Accepts exactly one signature-validated JPEG, PNG, or WebP file and enforces the application’s 50 MiB limit before full inspection. - Uses the manually pinned, same-origin `secure-metadata v0.1.0` browser artifact. No npm package, CDN, runtime GitHub request, or automatic version check is used. -- Separates decoded values from opaque detected containers and presents `metadata-partial` as successful but non-exhaustive. “No supported metadata detected” is not a claim that the file contains no metadata. +- Shows the local source thumbnail, detected format, size, and a keyboard-accessible remove/reset path. Decoded values are primary; opaque containers, coverage, and diagnostics remain available in a native details disclosure. +- Presents `metadata-partial` as successful but non-exhaustive. “No supported metadata detected” is not a claim that the file contains no metadata. - Privacy Clean calls the library’s authoritative default policy: supported EXIF, XMP, IPTC, comments, PNG text metadata, and timestamps are removed while ICC color profiles are preserved. +- Customize exposes only supported class-level removal controls for the detected format. Unselected supported classes and unknown structures are preserved; individual metadata-value editing is not offered. - Keeps source bytes unchanged and never decodes pixels, creates Canvas, resizes, converts, changes quality, or re-encodes the image. - Calls `verifyMetadata` on cleaned bytes and requires a valid result with every policy check passing before saving. Invalid, incomplete, truncated, or mismatched results fail closed with no output write. - Derives MIME and the normalized `_clean` filename from the detected image format, not the supplied MIME type or extension. @@ -281,9 +283,10 @@ PDF Metadata Inspector & Cleaner is available at `/tools/pdf/metadata/` for one - The explicit metadata model inspects Title, Author, Subject, Keywords, Creator, Producer, Creation Date, and Modification Date from the standard PDF document-info dictionary. - Values remain raw in application state while the UI formats dates with `Intl.DateTimeFormat`, safely replaces surfaced null characters for display, and limits individual rendered values to 2,000 characters. Cleaning still targets the complete underlying field. -- Users can remove one or more selected fields or choose the explicit “Remove all supported metadata” path. Missing fields are shown consistently and cannot be selected. +- Shows a compact source card and decoded values first, with all eight supported document-info fields available in a native details disclosure. +- Privacy Clean removes every present supported document-info field. Customize exposes only the same eight class-level field controls; missing fields remain disabled and individual value editing is not offered. - Cleaning edits the loaded PDF with `pdf-lib` using `updateMetadata: false`; pages are not rasterized, copied from screenshots, or reconstructed. -- After serialization, the tool reloads the produced bytes, inspects all supported fields again, and reports cleared or retained selections from that serialized output. Page count, dimensions, and rotation must also match before the result is offered as successful. +- After serialization, the tool reloads the produced bytes and inspects all supported fields again. Every requested field must be absent, and page count, dimensions, and rotation must match before any output is written; a retained requested field fails closed. - Repeated cleaning continues from the previously verified output bytes. Loading or clearing a source releases the prior model, comparison, and retained byte references. - The original PDF is never modified. Saving uses the shared File System Access picker where available and the revoking Blob-download fallback elsewhere. diff --git a/docs/image-metadata-privacy.md b/docs/image-metadata-privacy.md index e2874e9..6f7623c 100644 --- a/docs/image-metadata-privacy.md +++ b/docs/image-metadata-privacy.md @@ -6,6 +6,8 @@ Inspection reports only structures supported by `secure-metadata v0.1.0`. Decode Privacy Clean uses the library’s exported `DEFAULT_CLEANING_POLICY` directly. It removes supported EXIF, XMP, IPTC, comments, ordinary PNG text metadata, and standalone timestamps while preserving ICC color profiles. Unknown structures are not guessed away. The original source bytes remain unchanged. +Customize exposes only the same supported metadata classes that apply to the detected JPEG, PNG, or WebP format. The user may choose which classes to remove and whether to preserve ICC, but cannot edit individual values or target unknown structures. Verification expectations are derived from that explicit policy, so only requested removals are required to be absent and intentionally preserved supported classes may remain. + The produced bytes are passed to `verifyMetadata` before any write or download. Every returned policy check must pass, the verification result must be valid, and inspection of the result must not be partial or truncated. Otherwise the operation fails closed and no output bytes are saved. This verifies only the metadata categories targeted by the supported policy; it does not establish anonymity, complete privacy, provenance, pixel privacy, steganography detection, or malware safety. ## Pinned dependency diff --git a/docs/ux-consistency-audit.md b/docs/ux-consistency-audit.md index c296e98..69f814a 100644 --- a/docs/ux-consistency-audit.md +++ b/docs/ux-consistency-audit.md @@ -88,3 +88,11 @@ Interactive browser QA was retried on 2026-08-26 against a temporary localhost s Sprint 17 localized shared brand, primary-navigation, footer-navigation, category-navigation, and local-processing-summary accessible names across all six interface languages. Homepage proof copy now states the bounded invariant—no production file-content upload—rather than the absolute word “Nothing,” and production PDF/Image category notes no longer mention planned cards that are not present. Static i18n and UX tests enforce these contracts across all 19 production pages. Interactive browser QA was retried on 2026-08-26 against a temporary localhost server. Browser control failed before navigation with `windows sandbox failed: helper_unknown_error: apply deny-read ACLs`. Visual rendering, responsive interaction, keyboard operation, browser-native save/download, and Network-panel checks remain **BLOCKED**, not passed. The complete open matrix is maintained in [v2 promotion QA](./v2-release-qa.md). + +## Sprint 18 Metadata UX follow-up + +Sprint 18 aligned the Image Metadata and PDF Metadata source cards, made decoded values the primary inspection surface, and moved opaque containers, coverage, diagnostics, and complete supported-field views into native details disclosures. Default Privacy Clean remains the primary action; Customize exposes only supported class-level controls and does not offer individual-value editing or broaden either tool’s documented scope. Both cleaners now require their requested verification checks to pass before any output write. + +Automated tests cover source reset and object-URL cleanup, decoded/opaque grouping, format-specific Image policies and expectations, PDF default/custom selection, retained-field fail-closed behavior, six-locale parity, responsive CSS contracts, security invariants, and the release gate. `git diff --check` and `node tests/run-all.mjs` passed on 2026-08-26. + +Interactive browser QA was attempted against `127.0.0.1:4173`, but the browser-control runtime terminated before navigation with `windows sandbox failed: helper_unknown_error: apply deny-read ACLs`. Visual rendering, keyboard interaction, responsive behavior, native save/download, and Network-panel observations remain **BLOCKED**, not passed. The live manual matrix is maintained in [v2 promotion QA](./v2-release-qa.md). diff --git a/docs/v2-release-qa.md b/docs/v2-release-qa.md index 0439904..303538f 100644 --- a/docs/v2-release-qa.md +++ b/docs/v2-release-qa.md @@ -86,6 +86,7 @@ Audited on 2026-08-26 against repository records and official npm registry metad | Shared input, queue, source, status, cancellation, save, and recovery checks | PASS | | Per-tool file, queue, dimension, pixel, render, and aggregate-work boundaries | PASS | | Pull-request CI | REQUIRED BEFORE MERGE | +| Sprint 18 Metadata source cards, decoded-first disclosure, safe customization, and fail-closed verification contracts | PASS | The Node runner reports `MODULE_TYPELESS_PACKAGE_JSON` warnings because a parent user-level package file does not declare a module type. The static repository intentionally has no package manager or build configuration; all tests execute successfully. @@ -113,7 +114,7 @@ For `/`, every production tool, all five category hubs, `/privacy/`, `/about/`, - [ ] Inspect the Network panel while adding and processing files; confirm no file-content upload and no unexpected runtime request. - [ ] Inspect saved outputs for correct type, name, ordering, dimensions/pages, transparency behavior, and openability. - [ ] Exercise corrupt, unsupported, oversized, encrypted/password-protected, cancellation, save-cancellation, and repeated-operation paths applicable to each tool. -- [ ] For both metadata tools, confirm partial/opaque wording remains bounded, cleaning matches the documented supported scope, verification failure prevents saving, and the source remains retryable. +- [ ] For both metadata tools, confirm the source card, remove/reset focus path, decoded-first summary, native details disclosure, and keyboard-accessible Customize controls across all six locales. Confirm partial/opaque wording remains bounded, default Privacy Clean and custom cleaning match the documented supported scope, verification failure prevents saving, and the source remains retryable. - [ ] Confirm Privacy links route only to the two production metadata tools and Scan/OCR and Media cards remain non-interactive planned content. ## Recording manual evidence diff --git a/js/i18n.js b/js/i18n.js index d9902f9..6a4e14a 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -8,6 +8,7 @@ import { imageResizeLocales } from "./locales/image-resize.js"; import { imageCompressorLocales } from "./locales/image-compressor.js"; import { imageMetadataLocales } from "./locales/image-metadata.js"; import { privacyHubLocales } from "./locales/privacy-hub.js"; +import { metadataUxLocales } from "./locales/metadata-ux.js"; const STORAGE_KEY = "secure-tools-language"; const baseTranslations = { en, ko, ja, es, de, fr }; @@ -18,7 +19,8 @@ export const translations = Object.fromEntries(Object.entries(baseTranslations). categories: { ...catalog.categories, image: { ...catalog.categories.image, metadata: imageMetadataLocales[language].categoryDescription } }, imageResize: imageResizeLocales[language].copy, imageCompressor: imageCompressorLocales[language].copy, - imageMetadata: imageMetadataLocales[language].copy, + imageMetadata: { ...imageMetadataLocales[language].copy, source: { ...imageMetadataLocales[language].copy.source, ...metadataUxLocales[language].image.source }, inspector: { ...imageMetadataLocales[language].copy.inspector, ...metadataUxLocales[language].image.inspector }, clean: { ...imageMetadataLocales[language].copy.clean, ...metadataUxLocales[language].image.clean }, policy: metadataUxLocales[language].image.policy }, + pdfMetadata: { ...catalog.pdfMetadata, source: { ...catalog.pdfMetadata.source, ...metadataUxLocales[language].pdf.source }, inspector: { ...catalog.pdfMetadata.inspector, ...metadataUxLocales[language].pdf.inspector }, actions: { ...catalog.pdfMetadata.actions, ...metadataUxLocales[language].pdf.actions }, custom: metadataUxLocales[language].pdf.custom, errors: { ...catalog.pdfMetadata.errors, ...metadataUxLocales[language].pdf.errors } }, privacyHub: privacyHubLocales[language].copy, }])); diff --git a/js/locales/metadata-ux.js b/js/locales/metadata-ux.js new file mode 100644 index 0000000..7ff5319 --- /dev/null +++ b/js/locales/metadata-ux.js @@ -0,0 +1,92 @@ +export const metadataUxLocales = { + en: { + image: { + source: { selectedLabel: "Selected source image", meta: "{format} · {size}", remove: "Remove source image" }, + inspector: { decodedTitle: "Decoded metadata", summary: "{groups} decoded groups · {additional} additional structures", noDecoded: "No supported metadata values were decoded.", additional: "{count} additional metadata structure(s) were detected but not fully decoded.", details: "View details", detailTitle: "All detected metadata", diagnosticsTitle: "Parser diagnostics" }, + clean: { customize: "Customize", customizeDescription: "Choose only metadata classes supported by the current format and verification layer.", customButton: "Clean with custom policy and save" }, + policy: { legend: "Custom cleaning policy", removeExif: "Remove EXIF", removeXmp: "Remove XMP", removeIptc: "Remove IPTC", removeComments: "Remove JPEG comments", removeTextMetadata: "Remove PNG text metadata", removeTimestamps: "Remove PNG timestamps", preserveIcc: "Preserve ICC color profile" }, + }, + pdf: { + source: { selectedLabel: "Selected source PDF", meta: "PDF · {size}", remove: "Remove source PDF" }, + inspector: { decodedTitle: "Decoded document metadata", summary: "{count} supported decoded field(s)", noDecoded: "No supported document-info values were found.", details: "View details", detailsHelp: "The detailed view includes every supported document-info field. It does not inspect XMP or hidden PDF structures." }, + actions: { privacyClean: "Privacy Clean and save PDF", customize: "Customize", customClean: "Clean selected fields and save" }, + custom: { description: "Select standard document-info fields to remove. This does not edit values or expand the supported PDF scope.", legend: "Fields to remove" }, + errors: { verification: "The requested metadata fields remained after verification, so no file was saved." }, + }, + }, + ko: { + image: { + source: { selectedLabel: "선택한 원본 이미지", meta: "{format} · {size}", remove: "원본 이미지 제거" }, + inspector: { decodedTitle: "해석된 메타데이터", summary: "해석된 그룹 {groups}개 · 추가 구조 {additional}개", noDecoded: "지원되는 메타데이터 값을 해석하지 못했습니다.", additional: "추가 메타데이터 구조 {count}개를 감지했지만 완전히 해석하지는 못했습니다.", details: "세부 정보 보기", detailTitle: "감지된 모든 메타데이터", diagnosticsTitle: "파서 진단" }, + clean: { customize: "사용자 지정", customizeDescription: "현재 형식과 검증 계층이 지원하는 메타데이터 종류만 선택합니다.", customButton: "사용자 지정 정책으로 정리하고 저장" }, + policy: { legend: "사용자 지정 정리 정책", removeExif: "EXIF 제거", removeXmp: "XMP 제거", removeIptc: "IPTC 제거", removeComments: "JPEG 주석 제거", removeTextMetadata: "PNG 텍스트 메타데이터 제거", removeTimestamps: "PNG 타임스탬프 제거", preserveIcc: "ICC 색상 프로필 유지" }, + }, + pdf: { + source: { selectedLabel: "선택한 원본 PDF", meta: "PDF · {size}", remove: "원본 PDF 제거" }, + inspector: { decodedTitle: "해석된 문서 메타데이터", summary: "지원되는 해석 필드 {count}개", noDecoded: "지원되는 문서 정보 값을 찾지 못했습니다.", details: "세부 정보 보기", detailsHelp: "세부 보기에는 지원되는 모든 문서 정보 필드가 표시됩니다. XMP나 숨겨진 PDF 구조는 검사하지 않습니다." }, + actions: { privacyClean: "개인정보 정리 후 PDF 저장", customize: "사용자 지정", customClean: "선택한 필드 정리 후 저장" }, + custom: { description: "제거할 표준 문서 정보 필드를 선택합니다. 값을 편집하거나 지원 PDF 범위를 넓히지 않습니다.", legend: "제거할 필드" }, + errors: { verification: "검증 후에도 요청한 메타데이터 필드가 남아 있어 파일을 저장하지 않았습니다." }, + }, + }, + ja: { + image: { + source: { selectedLabel: "選択した元画像", meta: "{format} · {size}", remove: "元画像を削除" }, + inspector: { decodedTitle: "デコード済みメタデータ", summary: "デコード済みグループ {groups} 件 · 追加構造 {additional} 件", noDecoded: "対応するメタデータ値はデコードされませんでした。", additional: "追加のメタデータ構造を {count} 件検出しましたが、完全にはデコードされていません。", details: "詳細を表示", detailTitle: "検出されたすべてのメタデータ", diagnosticsTitle: "パーサー診断" }, + clean: { customize: "カスタマイズ", customizeDescription: "現在の形式と検証レイヤーが対応するメタデータ分類だけを選択します。", customButton: "カスタムポリシーで消去して保存" }, + policy: { legend: "カスタム消去ポリシー", removeExif: "EXIF を削除", removeXmp: "XMP を削除", removeIptc: "IPTC を削除", removeComments: "JPEG コメントを削除", removeTextMetadata: "PNG テキストメタデータを削除", removeTimestamps: "PNG タイムスタンプを削除", preserveIcc: "ICC カラープロファイルを保持" }, + }, + pdf: { + source: { selectedLabel: "選択した元PDF", meta: "PDF · {size}", remove: "元PDFを削除" }, + inspector: { decodedTitle: "デコード済み文書メタデータ", summary: "対応するデコード済みフィールド {count} 件", noDecoded: "対応する文書情報の値は見つかりませんでした。", details: "詳細を表示", detailsHelp: "詳細表示には対応するすべての文書情報フィールドが含まれます。XMP や隠し PDF 構造は検査しません。" }, + actions: { privacyClean: "プライバシー消去して PDF を保存", customize: "カスタマイズ", customClean: "選択したフィールドを消去して保存" }, + custom: { description: "削除する標準文書情報フィールドを選択します。値の編集や PDF 対応範囲の拡張は行いません。", legend: "削除するフィールド" }, + errors: { verification: "検証後も指定したメタデータ項目が残っていたため、ファイルは保存されませんでした。" }, + }, + }, + es: { + image: { + source: { selectedLabel: "Imagen de origen seleccionada", meta: "{format} · {size}", remove: "Quitar imagen de origen" }, + inspector: { decodedTitle: "Metadatos decodificados", summary: "{groups} grupos decodificados · {additional} estructuras adicionales", noDecoded: "No se decodificaron valores de metadatos compatibles.", additional: "Se detectaron {count} estructuras de metadatos adicionales, pero no se decodificaron por completo.", details: "Ver detalles", detailTitle: "Todos los metadatos detectados", diagnosticsTitle: "Diagnósticos del analizador" }, + clean: { customize: "Personalizar", customizeDescription: "Elige solo clases de metadatos compatibles con el formato actual y la verificación.", customButton: "Limpiar con política personalizada y guardar" }, + policy: { legend: "Política de limpieza personalizada", removeExif: "Eliminar EXIF", removeXmp: "Eliminar XMP", removeIptc: "Eliminar IPTC", removeComments: "Eliminar comentarios JPEG", removeTextMetadata: "Eliminar metadatos de texto PNG", removeTimestamps: "Eliminar marcas de tiempo PNG", preserveIcc: "Conservar el perfil de color ICC" }, + }, + pdf: { + source: { selectedLabel: "PDF de origen seleccionado", meta: "PDF · {size}", remove: "Quitar PDF de origen" }, + inspector: { decodedTitle: "Metadatos del documento decodificados", summary: "{count} campos compatibles decodificados", noDecoded: "No se encontraron valores de información de documento compatibles.", details: "Ver detalles", detailsHelp: "La vista detallada incluye todos los campos de información de documento compatibles. No inspecciona XMP ni estructuras PDF ocultas." }, + actions: { privacyClean: "Limpieza de privacidad y guardar PDF", customize: "Personalizar", customClean: "Limpiar campos seleccionados y guardar" }, + custom: { description: "Selecciona campos estándar de información de documento para eliminarlos. No edita valores ni amplía el alcance PDF compatible.", legend: "Campos que se eliminarán" }, + errors: { verification: "Los campos de metadatos solicitados permanecieron tras la verificación, por lo que no se guardó ningún archivo." }, + }, + }, + de: { + image: { + source: { selectedLabel: "Ausgewähltes Quellbild", meta: "{format} · {size}", remove: "Quellbild entfernen" }, + inspector: { decodedTitle: "Dekodierte Metadaten", summary: "{groups} dekodierte Gruppen · {additional} zusätzliche Strukturen", noDecoded: "Keine unterstützten Metadatenwerte wurden dekodiert.", additional: "{count} zusätzliche Metadatenstrukturen wurden erkannt, aber nicht vollständig dekodiert.", details: "Details anzeigen", detailTitle: "Alle erkannten Metadaten", diagnosticsTitle: "Parserdiagnose" }, + clean: { customize: "Anpassen", customizeDescription: "Wählen Sie nur Metadatenklassen, die das aktuelle Format und die Verifikation unterstützen.", customButton: "Mit eigener Richtlinie bereinigen und speichern" }, + policy: { legend: "Eigene Bereinigungsrichtlinie", removeExif: "EXIF entfernen", removeXmp: "XMP entfernen", removeIptc: "IPTC entfernen", removeComments: "JPEG-Kommentare entfernen", removeTextMetadata: "PNG-Textmetadaten entfernen", removeTimestamps: "PNG-Zeitstempel entfernen", preserveIcc: "ICC-Farbprofil beibehalten" }, + }, + pdf: { + source: { selectedLabel: "Ausgewählte Quell-PDF", meta: "PDF · {size}", remove: "Quell-PDF entfernen" }, + inspector: { decodedTitle: "Dekodierte Dokumentmetadaten", summary: "{count} unterstützte dekodierte Felder", noDecoded: "Keine unterstützten Dokumentinfo-Werte gefunden.", details: "Details anzeigen", detailsHelp: "Die Detailansicht enthält alle unterstützten Dokumentinfo-Felder. XMP und verborgene PDF-Strukturen werden nicht untersucht." }, + actions: { privacyClean: "Datenschutzbereinigung und PDF speichern", customize: "Anpassen", customClean: "Ausgewählte Felder bereinigen und speichern" }, + custom: { description: "Wählen Sie zu entfernende Standard-Dokumentinfo-Felder. Werte werden nicht bearbeitet und der PDF-Prüfumfang wird nicht erweitert.", legend: "Zu entfernende Felder" }, + errors: { verification: "Die angeforderten Metadatenfelder waren nach der Prüfung noch vorhanden; daher wurde keine Datei gespeichert." }, + }, + }, + fr: { + image: { + source: { selectedLabel: "Image source sélectionnée", meta: "{format} · {size}", remove: "Retirer l’image source" }, + inspector: { decodedTitle: "Métadonnées décodées", summary: "{groups} groupes décodés · {additional} structures supplémentaires", noDecoded: "Aucune valeur de métadonnée prise en charge n’a été décodée.", additional: "{count} structures de métadonnées supplémentaires ont été détectées sans être entièrement décodées.", details: "Afficher les détails", detailTitle: "Toutes les métadonnées détectées", diagnosticsTitle: "Diagnostics de l’analyseur" }, + clean: { customize: "Personnaliser", customizeDescription: "Choisissez uniquement les classes prises en charge par le format actuel et la vérification.", customButton: "Nettoyer avec la règle personnalisée et enregistrer" }, + policy: { legend: "Règle de nettoyage personnalisée", removeExif: "Supprimer EXIF", removeXmp: "Supprimer XMP", removeIptc: "Supprimer IPTC", removeComments: "Supprimer les commentaires JPEG", removeTextMetadata: "Supprimer les métadonnées texte PNG", removeTimestamps: "Supprimer les horodatages PNG", preserveIcc: "Conserver le profil colorimétrique ICC" }, + }, + pdf: { + source: { selectedLabel: "PDF source sélectionné", meta: "PDF · {size}", remove: "Retirer le PDF source" }, + inspector: { decodedTitle: "Métadonnées du document décodées", summary: "{count} champs pris en charge décodés", noDecoded: "Aucune valeur d’information de document prise en charge n’a été trouvée.", details: "Afficher les détails", detailsHelp: "La vue détaillée contient tous les champs d’information de document pris en charge. Elle n’inspecte pas XMP ni les structures PDF masquées." }, + actions: { privacyClean: "Nettoyage de confidentialité et enregistrer le PDF", customize: "Personnaliser", customClean: "Nettoyer les champs sélectionnés et enregistrer" }, + custom: { description: "Sélectionnez les champs d’information de document standard à supprimer. Les valeurs ne sont pas modifiées et la portée PDF n’est pas élargie.", legend: "Champs à supprimer" }, + errors: { verification: "Les champs de métadonnées demandés subsistaient après vérification ; aucun fichier n’a donc été enregistré." }, + }, + }, +}; diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index e4d2898..96c6a23 100644 --- a/tests/i18n-quality.test.mjs +++ b/tests/i18n-quality.test.mjs @@ -45,7 +45,7 @@ function placeholders(value) { function testCatalogParityAndQuality() { assert.deepEqual([...Object.keys(translations)], [...languageNames.keys()]); const english = flatten(translations.en); - assert.equal(english.size, 724); + assert.equal(english.size, 759); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs index abf9805..286f993 100644 --- a/tests/image-metadata.test.mjs +++ b/tests/image-metadata.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { cleanAndVerifyImageMetadata, createCleanOutputPlan, inspectImageMetadata, PRIVACY_CLEAN_POLICY } from "../tools/image/metadata/metadata.js"; +import { cleanAndVerifyImageMetadata, createCleaningPolicy, createCleanOutputPlan, createVerificationExpectation, inspectImageMetadata, PRIVACY_CLEAN_POLICY } from "../tools/image/metadata/metadata.js"; import { buildCleaningModel, buildInspectionModel, formatMetadataValue, MAX_METADATA_VALUE_LENGTH } from "../tools/image/metadata/model.js"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -95,9 +95,12 @@ const partialModel = buildInspectionModel({ format: "jpeg", file: { size: 10 }, assert.equal(partialModel.successful, true, "metadata-partial is a successful, explicitly non-exhaustive inspection"); assert.equal(partialModel.cleanable, true); assert.equal(partialModel.groups[0].items[0].value.opaque, true); +assert.equal(partialModel.decodedCount, 0); assert.equal(partialModel.additionalCount, 1); assert.deepEqual(partialModel.decodedGroups, []); assert.equal(partialModel.groups[0].key, "xmp", "XMP remains a distinct namespace group"); -const categorized = buildInspectionModel({ cleanable: true, report: { inspectionStatus: "metadata-partial", entries: [{ id: "a", namespace: "exif", name: "Software", category: "software" }, { id: "b", namespace: "iptc", name: "Caption", category: "description" }, { id: "c", namespace: "unknown", name: "Tag", category: "not-known" }], diagnostics: [{ severity: "warning", code: "TEST_DIAGNOSTIC", offset: 42, message: "bounded detail" }] } }, labels); +const categorized = buildInspectionModel({ cleanable: true, report: { inspectionStatus: "metadata-partial", entries: [{ id: "a", namespace: "exif", name: "Software", category: "software", value: "Camera App" }, { id: "b", namespace: "iptc", name: "Caption", category: "description", value: "Decoded caption" }, { id: "c", namespace: "unknown", name: "Tag", category: "not-known" }], diagnostics: [{ severity: "warning", code: "TEST_DIAGNOSTIC", offset: 42, message: "bounded detail" }] } }, labels); assert.deepEqual(categorized.groups.map(({ key }) => key), ["software", "iptc", "other"]); +assert.deepEqual(categorized.decodedGroups.map(({ key }) => key), ["software", "iptc"]); +assert.equal(categorized.decodedCount, 2); assert.equal(categorized.decodedGroupCount, 2); assert.equal(categorized.additionalCount, 1); assert.match(categorized.diagnostics[0], /WARNING · TEST_DIAGNOSTIC · byte 42 · bounded detail/); assert.equal(buildInspectionModel({ report: { inspectionStatus: "format-only", entries: [], diagnostics: [] } }).coverageKey, "imageMetadata.coverage.format-only"); assert.equal(buildInspectionModel({ report: { inspectionStatus: "container-inspected", entries: [], diagnostics: [] } }).count, 0, "No supported metadata is distinct from a claim that no metadata exists"); @@ -120,6 +123,16 @@ assert.equal(cleanedReport.report.entries.some((entry) => entry.namespace === "p const resultModel = buildCleaningModel(cleaned); assert.equal(resultModel.valid, true); assert.equal(resultModel.filename, "holiday_clean.png"); assert.ok(cleaned.cleaned.preserved.some((change) => change.namespace === "icc"), "ICC is preserved by the authoritative policy"); const secondClean = await cleanAndVerifyImageMetadata(source); assert.deepEqual(secondClean.bytes, cleaned.bytes, "Cleaning is deterministic"); +const customPolicy = createCleaningPolicy({ removeExif: false, removeXmp: false, removeIptc: false, removeComments: false, removeTextMetadata: false, removeTimestamps: true, preserveIcc: false }); +assert.deepEqual(createVerificationExpectation(customPolicy, "png"), { requireNoPrivacyRelevantMetadata: false, exif: "ignore", xmp: "ignore", textMetadata: "ignore", timestamps: "absent", icc: "absent" }); +assert.deepEqual(createVerificationExpectation(customPolicy, "jpeg"), { requireNoPrivacyRelevantMetadata: false, exif: "ignore", xmp: "ignore", iptc: "ignore", comments: "ignore", icc: "absent" }); +assert.deepEqual(createVerificationExpectation(customPolicy, "webp"), { requireNoPrivacyRelevantMetadata: false, exif: "ignore", xmp: "ignore", icc: "absent" }); +const customCleaned = await cleanAndVerifyImageMetadata(source, customPolicy); +assert.equal(customCleaned.verification.valid, true); assert.deepEqual(customCleaned.verification.checks.map(({ namespace }) => namespace).sort(), ["icc", "png-time"]); +const customReport = (await inspectImageMetadata(new File([customCleaned.bytes], "custom.png", { type: "image/png" }))).report; +assert.equal(customReport.entries.some((entry) => entry.namespace === "png-text"), true, "Unselected PNG text is preserved"); +assert.equal(customReport.entries.some((entry) => entry.namespace === "png-time"), false, "Selected timestamp class is removed"); +assert.equal(customReport.entries.some((entry) => entry.namespace === "icc"), false, "Requested ICC removal is verified"); for (const [bytes, name, format, mimeType] of [ [metadataJpeg(), "camera.jpeg", "jpeg", "image/jpeg"], @@ -148,6 +161,19 @@ await assert.rejects(cleanAndVerifyImageMetadata({ bytes: sourceBytes, cleanable const html = read("tools/image/metadata/index.html"); const app = read("tools/image/metadata/app.js"); const adapter = read("tools/image/metadata/metadata.js"); const model = read("tools/image/metadata/model.js"); const category = read("tools/image/index.html"); assert.match(html, /type="file"[^>]*aria-describedby="drop-description"/); assert.doesNotMatch(html, /type="file"[^>]*multiple/); +assert.match(html, /id="source-card" class="source-card"[^>]*data-i18n-aria-label="imageMetadata\.source\.selectedLabel"[^>]*hidden/); +assert.match(html, /id="source-thumbnail" class="queue-thumbnail" alt=""/); +assert.match(html, /id="source-name" class="source-name"/); +assert.match(html, /id="clear-source" class="queue-action queue-action--remove"[^>]*data-i18n-aria-label="imageMetadata\.source\.remove"/); +assert.match(app, /URL\.createObjectURL\(state\.source\.file\)/); +assert.match(app, /URL\.revokeObjectURL\(state\.previewUrl\)/); +assert.match(app, /pagehide[^;]+releasePreview/); +assert.match(app, /releasePreview\(\); resetPolicy\(\); state\.source = null; state\.inspection = null; state\.result = null/); +assert.match(html, /id="inspection-details" class="inspection-details">]*data-i18n="imageMetadata\.inspector\.details"/); +assert.match(html, /id="metadata-groups" class="metadata-groups metadata-groups--primary"[\s\S]*id="additional-notice"[\s\S]*id="metadata-detail-groups"/); +assert.match(html, /id="customize-cleaning" class="clean-customization"[^>]*hidden[\s\S]*
/); +assert.equal((html.match(/data-policy-key=/g) || []).length, 7); +assert.match(app, /FORMAT_POLICY_KEYS[\s\S]*jpeg:[^\n]*removeIptc[^\n]*removeComments[\s\S]*png:[^\n]*removeTextMetadata[^\n]*removeTimestamps[\s\S]*webp:/); assert.match(html, /connect-src 'none'/); assert.match(html, /role="status" aria-live="polite"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); const requestIndex = app.indexOf("await requestSaveHandle"); const cleanIndex = app.indexOf("await cleanAndVerifyImageMetadata"); const writeIndex = app.indexOf("await writeBlobToHandle"); @@ -156,7 +182,10 @@ assert.ok(cleanIndex < writeIndex, "No bytes are written before cleaning and fai assert.match(app, /verification|Verified|verified/); assert.doesNotMatch(app + adapter + model, /innerHTML|insertAdjacentHTML|document\.write/); assert.doesNotMatch(app + adapter + model, /\bfetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|https?:\/\//); assert.doesNotMatch(adapter + model, /createImageBitmap|new Image|createElement\(["']canvas|drawImage|getContext/); -assert.match(adapter, /cleanMetadata\(source\.bytes, DEFAULT_CLEANING_POLICY\)/); +assert.match(adapter, /requestedPolicy = DEFAULT_CLEANING_POLICY/); +assert.match(adapter, /cleanMetadata\(source\.bytes, policy\)/); +assert.match(adapter, /verifyMetadata\(cleaned\.output, expectation\)/); +assert.match(adapter, /verification\.checks\.length > 0/); const applicationFiles = ["tools/image/metadata/app.js", "tools/image/metadata/model.js", "tools/image/metadata/metadata.js"]; assert.deepEqual(applicationFiles.filter((relative) => read(relative).includes("secure-metadata-0.1.0.browser.js")), ["tools/image/metadata/metadata.js"]); console.log("Image Metadata inspection, honest coverage, cleaning, verification, output, UI, privacy, and regression contracts passed."); diff --git a/tests/pdf-metadata.test.mjs b/tests/pdf-metadata.test.mjs index 0ddf719..c976642 100644 --- a/tests/pdf-metadata.test.mjs +++ b/tests/pdf-metadata.test.mjs @@ -145,6 +145,11 @@ function testUiArchitectureAndScope() { const vendor = JSON.parse(read("assets/vendor/pdf-lib/package.json")); assert.equal(vendor.version, "1.17.1"); assert.match(html, /