diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index e911593..4028ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats. +- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata. + ## 0.1.0 - 2026-09-01 - Added byte-signature inventory, claimed-vs-detected media types, safe names, diff --git a/README.md b/README.md index a1c415f..bc28dbe 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified. removed, preserved, generated, unsupported, or incompletely checked, and includes source/output SHA-256 hashes and a bounded decoded-pixel sample comparison. -5. Download an individual re-encoded image, a JSON report, or a ZIP containing - re-encoded images and the report. The report itself may be sensitive because - it contains source filenames and metadata values. +5. Download generic sequentially named outputs, a pseudonymized safe-share + report, or a ZIP containing both. The separate detailed report preserves + source filenames and metadata values and may itself be sensitive. +6. Apply a reusable selective-removal policy to the scan evidence. The policy + matrix records remove, preserve, and review categories for every input, + distinguishes verified full re-encoding from inspect-only formats, and can + be exported without source names or raw metadata values. + +The safe-share report deliberately omits source names, timestamps, hashes, +exact byte sizes, metadata values and offsets. It retains scanner coverage, +format/dimension facts and categorized counts; unusual combinations can still +be identifying, so the report remains subject to human review. + +Policy evidence is a decision aid, not a surgical metadata editor. Current +JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve +selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives, +Office and unknown inputs the policy honestly reports `inspect-only` or +`policy-not-executable` rather than promising a cleaned file. ## Format support @@ -107,7 +122,7 @@ npm run release:artifact This checks the manifest, types, lint, formatting, unit fixtures, production build, Toolbox contract, and Chromium/Firefox workflows, then creates the -deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The +deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The archive contains the static application, project documents, and exact runtime dependency licence texts. diff --git a/SOURCE.md b/SOURCE.md index 618fc8c..fdabb44 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Privacy Tools 0.1.0 is published at: +The corresponding source for Privacy Tools 0.2.0 is published at: -https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0 Build with Node.js 22 and npm 11: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ef8a284..933df24 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,9 +11,9 @@ Material runtime components include: | -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- | | ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; | | fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; | -| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell | +| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell | | React / React DOM | 19.2.8 | MIT | User interface | ExifReader is used unmodified. Its MPL-2.0 covered source remains available at diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8affc35..6974a8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object. decoded sample. `buildSanitizationReport` assigns verified/warning/failed based on explicit coverage and output findings. 6. `archive` serializes bounded deterministic JSON and creates stored ZIP - entries from application-generated `images/` paths and sanitized unique - names. + entries from application-generated `images/` paths. Safe-share archives use + generic sequential image names and a reduced report; detailed reports remain + an explicit, separate download for local auditing. +7. `policy` applies frozen, versioned category rules to normalized findings. It + creates pseudonymous per-file evidence across every inventoried format and + separately reports whether the requested removal can be executed by the + existing verified re-encode path. New policy profiles can be added without + changing a container parser. Pixel decoding and Canvas encoding currently run on the main browser context because portable cross-browser image encoder support is there; metadata parsing diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 8717d5a..3cb22dc 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up originals, or cloud/recipient copies. Review the visible output, destination, report, and surrounding files yourself. -The JSON report is sensitive by design: it can include original filenames, -hashes, timestamps, and metadata values. Share or retain it only intentionally. +The detailed JSON report is sensitive by design: it can include original +filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share +or retain it only intentionally. The separate safe-share report replaces file +identity with sequential pseudonyms and omits those source identifiers and raw +values. It retains formats, dimensions, coverage states, note counts, and +finding-category totals so it is useful for review without claiming anonymity. +Safe-share ZIPs likewise use generic sequential output names. + +Selective-removal policy evidence follows the same pseudonymization boundary. +It can show which finding categories a policy would remove, preserve, or send +for review, but it never upgrades inspection coverage and never calls a format +safe when no verified output exists. A requested “location-only” policy is +therefore non-executable with the current all-metadata pixel re-encode; users +receive evidence rather than a misleading partially preserved output. diff --git a/package-lock.json b/package-lock.json index 27acc8e..4ab316f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "privacy-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "privacy-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "exifreader": "4.44.0", "fflate": "0.8.2", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -44,23 +44,22 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", + "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", - "integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", + "version": "0.2.0", "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -68,17 +67,16 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" - }, - "engines": { - "node": ">=20" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index f079fe7..67d7be4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "privacy-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Inspect and remove shareable-file metadata locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,16 +39,16 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "exifreader": "4.44.0", "fflate": "0.8.2", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 731da62..2ab6a22 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index e911593..4028ec7 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats. +- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata. + ## 0.1.0 - 2026-09-01 - Added byte-signature inventory, claimed-vs-detected media types, safe names, diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index b143f48..1b15834 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- diff --git a/public/README.md b/public/README.md index a1c415f..bc28dbe 100644 --- a/public/README.md +++ b/public/README.md @@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified. removed, preserved, generated, unsupported, or incompletely checked, and includes source/output SHA-256 hashes and a bounded decoded-pixel sample comparison. -5. Download an individual re-encoded image, a JSON report, or a ZIP containing - re-encoded images and the report. The report itself may be sensitive because - it contains source filenames and metadata values. +5. Download generic sequentially named outputs, a pseudonymized safe-share + report, or a ZIP containing both. The separate detailed report preserves + source filenames and metadata values and may itself be sensitive. +6. Apply a reusable selective-removal policy to the scan evidence. The policy + matrix records remove, preserve, and review categories for every input, + distinguishes verified full re-encoding from inspect-only formats, and can + be exported without source names or raw metadata values. + +The safe-share report deliberately omits source names, timestamps, hashes, +exact byte sizes, metadata values and offsets. It retains scanner coverage, +format/dimension facts and categorized counts; unusual combinations can still +be identifying, so the report remains subject to human review. + +Policy evidence is a decision aid, not a surgical metadata editor. Current +JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve +selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives, +Office and unknown inputs the policy honestly reports `inspect-only` or +`policy-not-executable` rather than promising a cleaned file. ## Format support @@ -107,7 +122,7 @@ npm run release:artifact This checks the manifest, types, lint, formatting, unit fixtures, production build, Toolbox contract, and Chromium/Firefox workflows, then creates the -deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The +deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The archive contains the static application, project documents, and exact runtime dependency licence texts. diff --git a/public/SOURCE.md b/public/SOURCE.md index 618fc8c..fdabb44 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Privacy Tools 0.1.0 is published at: +The corresponding source for Privacy Tools 0.2.0 is published at: -https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0 Build with Node.js 22 and npm 11: diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index ef8a284..933df24 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -11,9 +11,9 @@ Material runtime components include: | -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- | | ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; | | fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; | -| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell | +| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell | | React / React DOM | 19.2.8 | MIT | User interface | ExifReader is used unmodified. Its MPL-2.0 covered source remains available at diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 8affc35..6974a8d 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object. decoded sample. `buildSanitizationReport` assigns verified/warning/failed based on explicit coverage and output findings. 6. `archive` serializes bounded deterministic JSON and creates stored ZIP - entries from application-generated `images/` paths and sanitized unique - names. + entries from application-generated `images/` paths. Safe-share archives use + generic sequential image names and a reduced report; detailed reports remain + an explicit, separate download for local auditing. +7. `policy` applies frozen, versioned category rules to normalized findings. It + creates pseudonymous per-file evidence across every inventoried format and + separately reports whether the requested removal can be executed by the + existing verified re-encode path. New policy profiles can be added without + changing a container parser. Pixel decoding and Canvas encoding currently run on the main browser context because portable cross-browser image encoder support is there; metadata parsing diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 8717d5a..3cb22dc 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up originals, or cloud/recipient copies. Review the visible output, destination, report, and surrounding files yourself. -The JSON report is sensitive by design: it can include original filenames, -hashes, timestamps, and metadata values. Share or retain it only intentionally. +The detailed JSON report is sensitive by design: it can include original +filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share +or retain it only intentionally. The separate safe-share report replaces file +identity with sequential pseudonyms and omits those source identifiers and raw +values. It retains formats, dimensions, coverage states, note counts, and +finding-category totals so it is useful for review without claiming anonymity. +Safe-share ZIPs likewise use generic sequential output names. + +Selective-removal policy evidence follows the same pseudonymization boundary. +It can show which finding categories a policy would remove, preserve, or send +for review, but it never upgrades inspection coverage and never calls a format +safe when no verified output exists. A requested “location-only” policy is +therefore non-executable with the current all-metadata pixel re-encode; users +receive evidence rather than a misleading partially preserved output. diff --git a/public/sw.js b/public/sw.js index ea09921..84b481d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "privacy-tools-shell-"; -const CACHE_NAME = CACHE_PREFIX + "0.1.0"; +const CACHE_NAME = CACHE_PREFIX + "0.2.0"; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { event.waitUntil( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 4236046..c06f3bd 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,12 +3,20 @@ "schemaVersion": 1, "id": "de.add-ideas.privacy-tools", "name": "Privacy Tools", - "version": "0.1.0", - "description": "Inspect and remove shareable-file metadata locally in the browser.", + "version": "0.2.0", + "description": "Inspect metadata and create policy-evidenced sharing copies locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["privacy", "files", "security"], - "tags": ["metadata", "exif", "privacy", "sanitize", "share"], + "tags": [ + "metadata", + "exif", + "privacy", + "sanitize", + "policy", + "evidence", + "share" + ], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +29,46 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "*/*", + "extensions": [], + "label": "Files for bounded metadata inventory" + } + ], + "produces": [ + { + "mediaType": "image/jpeg", + "extensions": [".jpg", ".jpeg"], + "label": "Verified re-encoded JPEG" + }, + { + "mediaType": "image/png", + "extensions": [".png"], + "label": "Verified re-encoded PNG" + }, + { + "mediaType": "image/webp", + "extensions": [".webp"], + "label": "Verified re-encoded WebP" + }, + { + "mediaType": "application/json", + "extensions": [".json"], + "label": "Inspection and policy evidence" + }, + { + "mediaType": "application/zip", + "extensions": [".zip"], + "label": "Safe-share bundle" + } + ] + }, + "capabilities": { + "required": ["canvas-2d", "web-crypto", "workers"], + "optional": ["create-image-bitmap"] + }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 6677894..3a4c529 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,14 +1,20 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; +import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import { createBatchArchive, createBatchReport, + createPolicyEvidence, + createSafeShareReport, + genericOutputName, + policyById, + SELECTIVE_REMOVAL_POLICIES, sanitizeStaticImage, scanFilesInWorker, serializeReport, type FindingCategory, type ImageScanResult, + type SelectiveRemovalPolicy, type SanitizedAsset, } from "../privacy"; @@ -50,6 +56,9 @@ export function Workbench() { const [progress, setProgress] = useState(null); const [error, setError] = useState(""); const [dragging, setDragging] = useState(false); + const [genericNames, setGenericNames] = useState(true); + const [policyId, setPolicyId] = + useState("safe-share"); const abortRef = useRef(null); const inputRef = useRef(null); const cleanable = useMemo( @@ -60,6 +69,16 @@ export function Workbench() { () => records.flatMap((record) => (record.asset ? [record.asset] : [])), [records], ); + const policy = useMemo(() => policyById(policyId), [policyId]); + const policyEvidence = useMemo( + () => + createPolicyEvidence( + records.map((record) => record.scan), + assets, + policy, + ), + [assets, policy, records], + ); useEffect( () => () => { @@ -119,6 +138,12 @@ export function Workbench() { try { const asset = await sanitizeStaticImage(record.file, record.scan, { signal: controller.signal, + outputName: genericNames + ? genericOutputName( + records.findIndex((item) => item.scan.id === id), + record.scan.identity.detectedKind, + ) + : undefined, }); setRecords((current) => current.map((item) => @@ -173,6 +198,12 @@ export function Workbench() { try { const asset = await sanitizeStaticImage(record.file, record.scan, { signal: controller.signal, + outputName: genericNames + ? genericOutputName( + records.findIndex((item) => item.scan.id === record.scan.id), + record.scan.identity.detectedKind, + ) + : undefined, }); setRecords((current) => current.map((item) => @@ -222,6 +253,36 @@ export function Workbench() { } }; + const downloadSafeReport = () => { + setError(""); + try { + const report = createSafeShareReport( + records.map((record) => record.scan), + assets, + ); + triggerBlobDownload( + new Blob([serializeReport(report)], { type: "application/json" }), + "privacy-tools-safe-share-report.json", + ); + } catch (caught) { + setError(errorMessage(caught)); + } + }; + + const downloadPolicyEvidence = () => { + setError(""); + try { + triggerBlobDownload( + new Blob([serializeReport(policyEvidence)], { + type: "application/json", + }), + `privacy-tools-${policy.id}-policy-evidence.json`, + ); + } catch (caught) { + setError(errorMessage(caught)); + } + }; + const downloadArchive = async () => { if (assets.length === 0) return; setBusy("archive"); @@ -230,6 +291,8 @@ export function Workbench() { const blob = await createBatchArchive( records.map((record) => record.scan), assets, + undefined, + "safe-share", ); triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip"); } catch (caught) { @@ -343,6 +406,15 @@ export function Workbench() {

+
+
+
+
+
+

Reusable policy evidence

+

Selective-removal policy

+

{policy.description}

+
+
+ + +
+
+
+ + + + + + + + + + + + {policyEvidence.files.map((file) => ( + + + + + + + + ))} + +
Evidence IDFormatDecisionOperationRemove / preserve / review
{file.fileId}{file.detectedKind} + + {file.availableOperation} + {file.findings.remove} / {file.findings.preserve} /{" "} + {file.findings.review} +
+
+

+ A policy can require preservation that the pixel re-encoder cannot + guarantee. In that case the evidence says “policy-not-executable” + instead of silently discarding metadata. Unsupported formats + remain inspect-only. +

+
) : null} @@ -672,12 +818,6 @@ function groupFindings( return [...groups.entries()]; } -function formatBytes(value: number): string { - if (value < 1024) return `${value} B`; - if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`; - return `${(value / 1024 ** 2).toFixed(1)} MiB`; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The operation failed."; } diff --git a/src/privacy/archive.ts b/src/privacy/archive.ts index 090e974..94a4cfd 100644 --- a/src/privacy/archive.ts +++ b/src/privacy/archive.ts @@ -13,9 +13,13 @@ import { } from "./limits"; import type { BatchReport, + FindingCategory, + FindingRisk, ImageScanResult, + MetadataFinding, + SafeShareFindingSummary, + SafeShareReport, SanitizedAsset, - SanitizationReport, } from "./model"; export function createBatchReport( @@ -36,9 +40,63 @@ export function createBatchReport( }; } -export function serializeReport( - report: BatchReport | SanitizationReport, -): string { +export function createSafeShareReport( + files: readonly ImageScanResult[], + assets: readonly SanitizedAsset[], + generatedAt = new Date().toISOString(), +): SafeShareReport { + const fileIds = new Map( + files.map((file, index) => [ + file.id, + `file-${String(index + 1).padStart(3, "0")}`, + ]), + ); + return { + schemaVersion: 1, + profile: "safe-share", + generatedAt, + application: { name: "Privacy Tools", version: APP_VERSION }, + files: files.map((file, index) => ({ + fileId: + fileIds.get(file.id) ?? `file-${String(index + 1).padStart(3, "0")}`, + detectedKind: file.identity.detectedKind, + detectedType: file.identity.detectedType, + typeMatch: file.identity.typeMatch, + dimensions: + file.width !== undefined && file.height !== undefined + ? { width: file.width, height: file.height } + : undefined, + animated: file.animated, + multiImage: file.multiImage, + coverage: { + projectScanner: file.coverage.projectScanner, + secondaryScanner: file.coverage.secondaryScanner, + noteCount: file.coverage.notes.length, + }, + findings: summarizeFindings(file.findings), + warningCount: file.warnings.length, + })), + sanitizations: assets.map((asset, index) => ({ + fileId: fileIds.get(asset.report.sourceId) ?? "unknown-file", + outputName: genericNameForAsset(asset, index), + outputType: asset.report.outputType, + status: asset.report.status, + orientationNormalized: asset.report.orientationNormalized, + removed: summarizeFindings(asset.report.removed), + preserved: summarizeFindings(asset.report.preserved), + generated: summarizeFindings(asset.report.generated), + unsupported: [...asset.report.unsupported], + incomplete: [...asset.report.incomplete], + })), + warnings: [ + "This safe-share profile omits source filenames, timestamps, hashes, exact byte sizes, metadata values and offsets.", + "Counts, dimensions and format details can still identify unusual files; review before sharing.", + "A successful re-scan is not an anonymity guarantee.", + ], + }; +} + +export function serializeReport(report: object): string { return stableStringify(report, 2, { maxDepth: 64, maxNodes: 500_000, @@ -50,6 +108,7 @@ export async function createBatchArchive( files: readonly ImageScanResult[], assets: readonly SanitizedAsset[], generatedAt = new Date().toISOString(), + reportProfile: "detailed" | "safe-share" = "detailed", ): Promise { assertLimit( files.length, @@ -67,8 +126,13 @@ export async function createBatchArchive( >; const names = new Set(); let total = 0; - for (const asset of assets) { - const name = uniqueName(asset.report.outputName, names); + for (const [index, asset] of assets.entries()) { + const name = uniqueName( + reportProfile === "safe-share" + ? genericNameForAsset(asset, index) + : asset.report.outputName, + names, + ); const bytes = new Uint8Array(await asset.blob.arrayBuffer()); total += bytes.byteLength; if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) @@ -80,7 +144,11 @@ export async function createBatchArchive( entries[`images/${name}`] = bytes; } const report = encodeText( - serializeReport(createBatchReport(files, assets, generatedAt)), + serializeReport( + reportProfile === "safe-share" + ? createSafeShareReport(files, assets, generatedAt) + : createBatchReport(files, assets, generatedAt), + ), ); total += report.byteLength; if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) @@ -104,6 +172,32 @@ export async function createBatchArchive( return new Blob([ownedArchive], { type: "application/zip" }); } +function summarizeFindings( + findings: readonly MetadataFinding[], +): SafeShareFindingSummary { + const byRisk: Record = { + sensitive: 0, + context: 0, + technical: 0, + }; + const byCategory: Partial> = {}; + for (const finding of findings) { + byRisk[finding.risk] += 1; + byCategory[finding.category] = (byCategory[finding.category] ?? 0) + 1; + } + return { total: findings.length, byRisk, byCategory }; +} + +function genericNameForAsset(asset: SanitizedAsset, index: number): string { + const extension = + asset.report.outputType === "image/jpeg" + ? "jpg" + : asset.report.outputType === "image/webp" + ? "webp" + : "png"; + return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`; +} + function uniqueName(input: string, used: Set): string { const safe = sanitizeDownloadFilename(input, "image.clean"); if (!used.has(safe)) { diff --git a/src/privacy/index.ts b/src/privacy/index.ts index b92c06a..41f6e61 100644 --- a/src/privacy/index.ts +++ b/src/privacy/index.ts @@ -2,6 +2,7 @@ export * from "./archive"; export * from "./detect"; export * from "./limits"; export * from "./model"; +export * from "./policy"; export * from "./sanitize"; export * from "./scan-client"; export * from "./scanner"; diff --git a/src/privacy/model.ts b/src/privacy/model.ts index 2cb3a57..96496a7 100644 --- a/src/privacy/model.ts +++ b/src/privacy/model.ts @@ -131,12 +131,60 @@ export interface SanitizedAsset { export interface BatchReport { schemaVersion: 1; generatedAt: string; - application: { name: "Privacy Tools"; version: "0.1.0" }; + application: { + name: "Privacy Tools"; + version: typeof import("../version").APP_VERSION; + }; files: ImageScanResult[]; sanitizations: SanitizationReport[]; warnings: string[]; } +export interface SafeShareFindingSummary { + total: number; + byRisk: Record; + byCategory: Partial>; +} + +export interface SafeShareReport { + schemaVersion: 1; + profile: "safe-share"; + generatedAt: string; + application: { + name: "Privacy Tools"; + version: typeof import("../version").APP_VERSION; + }; + files: Array<{ + fileId: string; + detectedKind: DetectedKind; + detectedType: string; + typeMatch: InventoryIdentity["typeMatch"]; + dimensions?: { width: number; height: number }; + animated: boolean; + multiImage: boolean; + coverage: { + projectScanner: ParserCoverage["projectScanner"]; + secondaryScanner: ParserCoverage["secondaryScanner"]; + noteCount: number; + }; + findings: SafeShareFindingSummary; + warningCount: number; + }>; + sanitizations: Array<{ + fileId: string; + outputName: string; + outputType: string; + status: SanitizationStatus; + orientationNormalized: boolean; + removed: SafeShareFindingSummary; + preserved: SafeShareFindingSummary; + generated: SafeShareFindingSummary; + unsupported: string[]; + incomplete: string[]; + }>; + warnings: string[]; +} + export interface PrivacyLimits { maxFiles: number; maxFileBytes: number; diff --git a/src/privacy/policy.ts b/src/privacy/policy.ts new file mode 100644 index 0000000..a521f6b --- /dev/null +++ b/src/privacy/policy.ts @@ -0,0 +1,266 @@ +import type { + FindingCategory, + ImageScanResult, + MetadataFinding, + SanitizedAsset, +} from "./model"; + +export type PolicyAction = "remove" | "preserve" | "review"; + +export interface SelectiveRemovalPolicy { + readonly id: "safe-share" | "location-only" | "archive-review"; + readonly name: string; + readonly description: string; + readonly actions: Readonly>; +} + +export interface PolicyFileEvidence { + readonly fileId: string; + readonly detectedKind: ImageScanResult["identity"]["detectedKind"]; + readonly coverage: ImageScanResult["coverage"]; + readonly availableOperation: + | "verified-reencode" + | "reencode-available" + | "inspect-only" + | "policy-not-executable"; + readonly decision: "verified" | "review" | "blocked" | "no-target-findings"; + readonly findings: Readonly>; + readonly requiredRemovals: readonly string[]; + readonly preservedAgainstPolicy: readonly string[]; + readonly removedAgainstPolicy: readonly string[]; + readonly unresolved: readonly string[]; +} + +export interface PolicyEvidence { + readonly schemaVersion: 1; + readonly profile: "selective-removal-evidence"; + readonly generatedAt: string; + readonly policy: SelectiveRemovalPolicy; + readonly files: readonly PolicyFileEvidence[]; + readonly limitations: readonly string[]; +} + +const categories: readonly FindingCategory[] = [ + "location", + "identity", + "timestamp", + "device", + "software", + "document-id", + "comment", + "thumbnail", + "colour-profile", + "provenance", + "technical", + "unknown", +]; + +function actions( + remove: readonly FindingCategory[], + preserve: readonly FindingCategory[] = [], +): Readonly> { + const removed = new Set(remove); + const preserved = new Set(preserve); + return Object.freeze( + Object.fromEntries( + categories.map((category) => [ + category, + removed.has(category) + ? "remove" + : preserved.has(category) + ? "preserve" + : "review", + ]), + ) as unknown as Record, + ); +} + +export const SELECTIVE_REMOVAL_POLICIES: readonly SelectiveRemovalPolicy[] = + Object.freeze([ + Object.freeze({ + id: "safe-share", + name: "Strict safe-share", + description: + "Remove sensitive, contextual, preview and provenance metadata; independently review technical and colour-profile output.", + actions: actions([ + "location", + "identity", + "timestamp", + "device", + "software", + "document-id", + "comment", + "thumbnail", + "provenance", + "unknown", + ]), + }), + Object.freeze({ + id: "location-only", + name: "Location-only request", + description: + "Request removal of location metadata while preserving other metadata. The current pixel re-encoder cannot promise this selective preservation.", + actions: actions( + ["location"], + [ + "identity", + "timestamp", + "device", + "software", + "document-id", + "comment", + "thumbnail", + "colour-profile", + "provenance", + "technical", + ], + ), + }), + Object.freeze({ + id: "archive-review", + name: "Archival review", + description: + "Preserve provenance, colour and technical context while removing direct location and identity clues; all other categories require review.", + actions: actions( + ["location", "identity", "document-id"], + ["colour-profile", "provenance", "technical"], + ), + }), + ]); + +export function policyById( + id: SelectiveRemovalPolicy["id"], +): SelectiveRemovalPolicy { + const policy = SELECTIVE_REMOVAL_POLICIES.find((item) => item.id === id); + if (!policy) throw new TypeError(`Unknown privacy policy: ${id}`); + return policy; +} + +function counts( + findings: readonly MetadataFinding[], + policy: SelectiveRemovalPolicy, +): Record { + const result: Record = { + remove: 0, + preserve: 0, + review: 0, + }; + findings.forEach((finding) => { + result[policy.actions[finding.category]] += 1; + }); + return result; +} + +function signatures(findings: readonly MetadataFinding[]): string[] { + return findings.map( + (finding) => `${finding.category}: ${finding.source} / ${finding.label}`, + ); +} + +export function createPolicyEvidence( + files: readonly ImageScanResult[], + assets: readonly SanitizedAsset[], + policy: SelectiveRemovalPolicy, + generatedAt = new Date().toISOString(), +): PolicyEvidence { + if (files.length > 100 || assets.length > 100) + throw new RangeError( + "Policy evidence is limited to 100 files and outputs.", + ); + const outputBySource = new Map( + assets.map((asset) => [asset.report.sourceId, asset.report]), + ); + return Object.freeze({ + schemaVersion: 1, + profile: "selective-removal-evidence", + generatedAt, + policy, + files: Object.freeze( + files.map((file, index): PolicyFileEvidence => { + const report = outputBySource.get(file.id); + const grouped = counts(file.findings, policy); + const required = file.findings.filter( + (finding) => policy.actions[finding.category] === "remove", + ); + const mustPreserve = file.findings.filter( + (finding) => policy.actions[finding.category] === "preserve", + ); + const preservedAgainstPolicy = report + ? report.preserved.filter( + (finding) => policy.actions[finding.category] === "remove", + ) + : []; + const removedAgainstPolicy = report + ? report.removed.filter( + (finding) => policy.actions[finding.category] === "preserve", + ) + : []; + const generatedAgainstPolicy = report + ? report.generated.filter( + (finding) => policy.actions[finding.category] === "remove", + ) + : []; + const unresolved: string[] = []; + if (file.coverage.projectScanner !== "complete") + unresolved.push("Project scanner coverage is not complete."); + if (file.coverage.secondaryScanner !== "complete") + unresolved.push("Secondary scanner coverage is not complete."); + if (!file.deepSupported) + unresolved.push( + "This format has inventory evidence only; metadata removal is unsupported.", + ); + if (mustPreserve.length && file.cleanable) + unresolved.push( + "The pixel re-encoder cannot guarantee selective preservation of requested metadata.", + ); + if (generatedAgainstPolicy.length) + unresolved.push( + `Output generated ${generatedAgainstPolicy.length} finding(s) marked for removal.`, + ); + if (report?.incomplete.length) unresolved.push(...report.incomplete); + const violations = + preservedAgainstPolicy.length + + removedAgainstPolicy.length + + generatedAgainstPolicy.length; + const availableOperation: PolicyFileEvidence["availableOperation"] = + report && violations === 0 + ? "verified-reencode" + : required.length === 0 + ? "inspect-only" + : !file.cleanable + ? "inspect-only" + : mustPreserve.length + ? "policy-not-executable" + : "reencode-available"; + const decision: PolicyFileEvidence["decision"] = + report && violations === 0 && unresolved.length === 0 + ? "verified" + : required.length === 0 && unresolved.length === 0 + ? "no-target-findings" + : violations > 0 || (required.length > 0 && !file.cleanable) + ? "blocked" + : "review"; + return Object.freeze({ + fileId: `file-${String(index + 1).padStart(3, "0")}`, + detectedKind: file.identity.detectedKind, + coverage: file.coverage, + availableOperation, + decision, + findings: grouped, + requiredRemovals: Object.freeze(signatures(required)), + preservedAgainstPolicy: Object.freeze( + signatures(preservedAgainstPolicy), + ), + removedAgainstPolicy: Object.freeze(signatures(removedAgainstPolicy)), + unresolved: Object.freeze(unresolved), + }); + }), + ), + limitations: Object.freeze([ + "Policy evidence summarizes bounded metadata scanners; it is not an anonymity guarantee.", + "Only supported static JPEG, PNG and WebP inputs can be pixel re-encoded. Other formats remain inspect-only.", + "Pixel re-encoding is an all-container rewrite, not a surgical metadata editor, so preservation policies can be non-executable.", + "Visible content, sidecars, watermarks, steganography and remote copies are outside this evidence.", + ]), + }); +} diff --git a/src/privacy/sanitize.ts b/src/privacy/sanitize.ts index 771b74c..6043f80 100644 --- a/src/privacy/sanitize.ts +++ b/src/privacy/sanitize.ts @@ -16,6 +16,8 @@ export interface SanitizeOptions { jpegQuality?: number; webpQuality?: number; signal?: AbortSignal; + /** A caller-selected, non-identifying output name. The detected extension is enforced. */ + outputName?: string; } export async function sanitizeStaticImage( @@ -72,10 +74,9 @@ export async function sanitizeStaticImage( ); throwIfAborted(options.signal); const outputBytes = await blob.arrayBuffer(); - const outputName = cleanOutputName( - source.safeName, - source.identity.detectedKind, - ); + const outputName = options.outputName + ? enforceOutputExtension(options.outputName, source.identity.detectedKind) + : cleanOutputName(source.safeName, source.identity.detectedKind); const outputScan = await scanImageBytes({ id: `${source.id}-clean`, name: outputName, @@ -393,6 +394,30 @@ function cleanOutputName( return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`); } +export function genericOutputName( + index: number, + kind: ImageScanResult["identity"]["detectedKind"], +): string { + if (!Number.isSafeInteger(index) || index < 0) + throw new RangeError( + "Generic output index must be a non-negative integer.", + ); + const extension = kind === "jpeg" ? "jpg" : kind; + if (!new Set(["jpg", "png", "webp"]).has(extension)) + throw new TypeError(`No generic clean-copy name is available for ${kind}.`); + return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`; +} + +function enforceOutputExtension( + input: string, + kind: ImageScanResult["identity"]["detectedKind"], +): string { + const extension = kind === "jpeg" ? "jpg" : kind; + const safe = sanitizeDownloadFilename(input, `image.clean.${extension}`); + const stem = safe.replace(/\.[^.]*$/u, "") || "image.clean"; + return `${stem}.${extension}`; +} + function findingSignature(finding: MetadataFinding): string { const label = finding.label .toLowerCase() diff --git a/src/styles.css b/src/styles.css index 33fa604..761ea4b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -317,6 +317,19 @@ button:disabled { flex-wrap: wrap; gap: 0.5rem; } +.privacy-option { + display: inline-flex; + gap: 0.45rem; + align-items: center; + min-height: 2.55rem; + color: var(--toolbox-muted); + font-size: 0.8rem; + font-weight: 650; +} +.privacy-option input { + width: auto; + min-height: auto; +} .inventory-table-wrap { overflow: auto; diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 4236046..c06f3bd 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,12 +3,20 @@ "schemaVersion": 1, "id": "de.add-ideas.privacy-tools", "name": "Privacy Tools", - "version": "0.1.0", - "description": "Inspect and remove shareable-file metadata locally in the browser.", + "version": "0.2.0", + "description": "Inspect metadata and create policy-evidenced sharing copies locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["privacy", "files", "security"], - "tags": ["metadata", "exif", "privacy", "sanitize", "share"], + "tags": [ + "metadata", + "exif", + "privacy", + "sanitize", + "policy", + "evidence", + "share" + ], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +29,46 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "*/*", + "extensions": [], + "label": "Files for bounded metadata inventory" + } + ], + "produces": [ + { + "mediaType": "image/jpeg", + "extensions": [".jpg", ".jpeg"], + "label": "Verified re-encoded JPEG" + }, + { + "mediaType": "image/png", + "extensions": [".png"], + "label": "Verified re-encoded PNG" + }, + { + "mediaType": "image/webp", + "extensions": [".webp"], + "label": "Verified re-encoded WebP" + }, + { + "mediaType": "application/json", + "extensions": [".json"], + "label": "Inspection and policy evidence" + }, + { + "mediaType": "application/zip", + "extensions": [".zip"], + "label": "Safe-share bundle" + } + ] + }, + "capabilities": { + "required": ["canvas-2d", "web-crypto", "workers"], + "optional": ["create-image-bitmap"] + }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/version.ts b/src/version.ts index 76162f8..0ba6e37 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.2.0"; diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts index 344a14d..4358c0d 100644 --- a/tests/browser/app.spec.ts +++ b/tests/browser/app.spec.ts @@ -42,7 +42,7 @@ test("serves the release identity and hardened headers", async ({ const manifest = await request.get("/deep/nested/privacy/toolbox-app.json"); await expect(manifest.json()).resolves.toMatchObject({ id: "de.add-ideas.privacy-tools", - version: "0.1.0", + version: "0.2.0", entry: "./", }); }); @@ -76,6 +76,14 @@ test("inspects and independently verifies a re-encoded PNG without network acces await expect(page.getByText("Alice PNG").first()).toBeVisible(); await expect(page.getByText("PNG Alice").first()).toBeVisible(); await expect(page.getByText("inventory-only.pdf").first()).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Selective-removal policy" }), + ).toBeVisible(); + await expect(page.getByText("inspect-only", { exact: true })).toBeVisible(); + await page.getByLabel("Policy profile").selectOption("location-only"); + await expect( + page.getByText("policy-not-executable", { exact: true }), + ).toBeVisible(); await page.getByRole("button", { name: "Re-encode & verify" }).click(); await expect(page.getByText("Mandatory output re-scan")).toBeVisible(); await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u); @@ -87,18 +95,26 @@ test("inspects and independently verifies a re-encoded PNG without network acces await page .getByRole("button", { name: "Download re-encoded output" }) .click(); - expect((await imageDownload).suggestedFilename()).toBe( - "metadata-fixture.clean.png", + expect((await imageDownload).suggestedFilename()).toBe("image-001.clean.png"); + + const safeReportDownload = page.waitForEvent("download"); + await page + .getByRole("button", { name: "Download safe-share report" }) + .click(); + expect((await safeReportDownload).suggestedFilename()).toBe( + "privacy-tools-safe-share-report.json", ); - const reportDownload = page.waitForEvent("download"); - await page.getByRole("button", { name: "Download JSON report" }).click(); - expect((await reportDownload).suggestedFilename()).toBe( + const detailedReportDownload = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download detailed report" }).click(); + expect((await detailedReportDownload).suggestedFilename()).toBe( "privacy-tools-report.json", ); const archiveDownload = page.waitForEvent("download"); await page - .getByRole("button", { name: "Download 1 re-encoded image + report" }) + .getByRole("button", { + name: "Download safe-share ZIP with 1 re-encoded image + report", + }) .click(); expect((await archiveDownload).suggestedFilename()).toBe( "privacy-tools-re-encoded-images.zip", diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..c3d2210 --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("keeps the primary workspace inside a narrow viewport", async ({ + page, +}) => { + await page.goto("/deep/nested/privacy/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect( + page.locator("main .loading, main .workbench-loading"), + ).toHaveCount(0); + + const widths = await page.evaluate(() => ({ + content: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.viewport).toBeLessThanOrEqual(430); + expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1); +}); diff --git a/tests/privacy/report.test.ts b/tests/privacy/report.test.ts index 2afb959..993ef19 100644 --- a/tests/privacy/report.test.ts +++ b/tests/privacy/report.test.ts @@ -7,6 +7,10 @@ import { buildSanitizationReport, createBatchArchive, createBatchReport, + createPolicyEvidence, + createSafeShareReport, + genericOutputName, + policyById, serializeReport, type ImageScanResult, type MetadataFinding, @@ -160,6 +164,98 @@ describe("sanitization and batch reports", () => { ), ).rejects.toThrow(/Archive image count/iu); }); + + it("builds a pseudonymized safe-share report and generic names", () => { + const source = result({ + id: "private-id", + name: "Alice-at-home.png", + sha256: "secret-hash", + findings: [sensitive], + coverage: { + projectScanner: "complete", + secondaryScanner: "complete", + notes: ["source metadata value"], + }, + }); + const output = result({ id: "clean", name: "output.png" }); + const report = buildSanitizationReport( + source, + output, + "Alice-at-home.clean.png", + "image/png", + 20, + comparison(true), + ); + const safe = serializeReport( + createSafeShareReport( + [source], + [{ blob: new Blob(), report }], + "2026-09-01T00:00:00.000Z", + ), + ); + expect(safe).toContain('"profile": "safe-share"'); + expect(safe).toContain("image-001.clean.png"); + expect(safe).not.toContain("Alice"); + expect(safe).not.toContain("secret-hash"); + expect(safe).not.toContain("48.1 N"); + expect(safe).not.toContain("source metadata value"); + expect(genericOutputName(9, "jpeg")).toBe("image-010.clean.jpg"); + }); + + it("evaluates reusable policies across cleanable and inspect-only formats", () => { + const cleanable = result({ findings: [sensitive] }); + const unsupported = result({ + id: "pdf", + name: "document.pdf", + identity: { + claimedType: "application/pdf", + extension: "pdf", + detectedKind: "pdf", + detectedType: "application/pdf", + typeMatch: "match", + }, + deepSupported: false, + cleanable: false, + findings: [sensitive], + coverage: { + projectScanner: "unsupported", + secondaryScanner: "unsupported", + notes: [], + }, + }); + const strict = createPolicyEvidence( + [cleanable, unsupported], + [], + policyById("safe-share"), + "2026-09-01T00:00:00.000Z", + ); + expect(strict.files[0]).toMatchObject({ + decision: "review", + availableOperation: "reencode-available", + findings: { remove: 1 }, + }); + expect(strict.files[1]).toMatchObject({ + decision: "blocked", + availableOperation: "inspect-only", + }); + expect(JSON.stringify(strict)).not.toContain("document.pdf"); + + const selective = createPolicyEvidence( + [ + result({ + findings: [ + sensitive, + { ...sensitive, id: "author", category: "identity" }, + ], + }), + ], + [], + policyById("location-only"), + ); + expect(selective.files[0]?.availableOperation).toBe( + "policy-not-executable", + ); + }); }); function comparison(identical: boolean) {