Release Privacy Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:09:14 +02:00
parent bfd6422149
commit 38fa45dbaa
31 changed files with 1039 additions and 95 deletions
+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)) {