1 Commits
Author SHA1 Message Date
zemion 38fa45dbaa Release Privacy Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 10:09:14 +02:00
31 changed files with 1039 additions and 95 deletions
+39
View File
@@ -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
+5
View File
@@ -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,
+19 -4
View File
@@ -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.
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
| `@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
+8 -2
View File
@@ -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
+14 -2
View File
@@ -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.
+19 -21
View File
@@ -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": {
+5 -5
View File
@@ -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",
+20 -2
View File
@@ -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"] },
},
],
});
+5
View File
@@ -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,
+3 -3
View File
@@ -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 ---
+19 -4
View File
@@ -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.
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
| `@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
+8 -2
View File
@@ -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
+14 -2
View File
@@ -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.
+1 -1
View File
@@ -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(
+51 -3
View File
@@ -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,
+151 -11
View File
@@ -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<Progress | null>(null);
const [error, setError] = useState("");
const [dragging, setDragging] = useState(false);
const [genericNames, setGenericNames] = useState(true);
const [policyId, setPolicyId] =
useState<SelectiveRemovalPolicy["id"]>("safe-share");
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(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() {
</p>
</div>
<div className="button-row">
<label className="privacy-option">
<input
type="checkbox"
checked={genericNames}
disabled={busy !== null || assets.length > 0}
onChange={(event) => setGenericNames(event.target.checked)}
/>
Use generic sequential output names
</label>
<button
type="button"
className="primary-button"
@@ -446,17 +518,25 @@ export function Workbench() {
<p className="eyebrow">Step 3</p>
<h2 id="export-heading">Export deliberately</h2>
<p className="muted">
The JSON report includes original filenames and detected
values and may itself be sensitive.
Safe-share reports omit names, hashes, exact sizes, values and
offsets. Detailed reports retain that evidence and may
themselves be sensitive.
</p>
</div>
<div className="button-row">
<button
type="button"
onClick={downloadSafeReport}
disabled={busy !== null}
>
Download safe-share report
</button>
<button
type="button"
onClick={downloadReport}
disabled={busy !== null}
>
Download JSON report
Download detailed report
</button>
<button
type="button"
@@ -464,12 +544,78 @@ export function Workbench() {
onClick={() => void downloadArchive()}
disabled={busy !== null || assets.length === 0}
>
Download {assets.length} re-encoded{" "}
Download safe-share ZIP with {assets.length} re-encoded{" "}
{assets.length === 1 ? "image" : "images"} + report
</button>
</div>
</div>
</section>
<section className="panel" aria-labelledby="policy-heading">
<div className="panel-heading action-heading">
<div>
<p className="eyebrow">Reusable policy evidence</p>
<h2 id="policy-heading">Selective-removal policy</h2>
<p className="muted">{policy.description}</p>
</div>
<div className="button-row">
<label className="policy-select">
<span>Policy profile</span>
<select
value={policyId}
onChange={(event) =>
setPolicyId(
event.target.value as SelectiveRemovalPolicy["id"],
)
}
>
{SELECTIVE_REMOVAL_POLICIES.map((item) => (
<option value={item.id} key={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button type="button" onClick={downloadPolicyEvidence}>
Download policy evidence
</button>
</div>
</div>
<div className="inventory-table-wrap">
<table className="inventory-table">
<thead>
<tr>
<th>Evidence ID</th>
<th>Format</th>
<th>Decision</th>
<th>Operation</th>
<th>Remove / preserve / review</th>
</tr>
</thead>
<tbody>
{policyEvidence.files.map((file) => (
<tr key={file.fileId}>
<td>{file.fileId}</td>
<td>{file.detectedKind}</td>
<td>
<StatusBadge status={file.decision} />
</td>
<td>{file.availableOperation}</td>
<td>
{file.findings.remove} / {file.findings.preserve} /{" "}
{file.findings.review}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="boundary-inline">
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.
</p>
</section>
</>
) : 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.";
}
+101 -7
View File
@@ -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<Blob> {
assertLimit(
files.length,
@@ -67,8 +126,13 @@ export async function createBatchArchive(
>;
const names = new Set<string>();
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<FindingRisk, number> = {
sensitive: 0,
context: 0,
technical: 0,
};
const byCategory: Partial<Record<FindingCategory, number>> = {};
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>): string {
const safe = sanitizeDownloadFilename(input, "image.clean");
if (!used.has(safe)) {
+1
View File
@@ -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";
+49 -1
View File
@@ -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<FindingRisk, number>;
byCategory: Partial<Record<FindingCategory, number>>;
}
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;
+266
View File
@@ -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<Record<FindingCategory, PolicyAction>>;
}
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<Record<PolicyAction, number>>;
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<Record<FindingCategory, PolicyAction>> {
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<FindingCategory, PolicyAction>,
);
}
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<PolicyAction, number> {
const result: Record<PolicyAction, number> = {
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.",
]),
});
}
+29 -4
View File
@@ -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()
+13
View File
@@ -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;
+51 -3
View File
@@ -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,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+23 -7
View File
@@ -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",
+18
View File
@@ -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);
});
+96
View File
@@ -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) {