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

This commit is contained in:
2026-09-02 11:29:24 +02:00
parent e42943e689
commit 01c7a8c372
32 changed files with 1316 additions and 85 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
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 0.2.0 - 2026-09-02
- Replaced CRC/size equality with bounded on-demand decompressed SHA-256 and
safe semantic text, JSON, XML and manifest diffs.
- Added bounded dependency and licence declarations, signature-companion and
declared-digest diagnostics, plus verified-entry Web Share handoff.
- Declared accurate package input, report/extracted-file output and optional
browser capability profiles for Toolbox handoff.
## 0.1.0 - 2026-09-01
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
+16 -4
View File
@@ -6,7 +6,7 @@ bounded, format-aware interpretation for EPUB, OOXML (`.docx`, `.xlsx`,
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
extension packages.
Version 0.1.0 can:
Version 0.2.0 can:
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
- reject traversal, ambiguous, colliding and special-entry paths;
@@ -14,12 +14,21 @@ Version 0.1.0 can:
- interpret OOXML content types and internal/external relationships;
- inspect EPUB container/package metadata and manifest/spine consistency;
- inspect ODF manifests, JAR manifests and WebExtension manifests;
- inventory known signature-material files without asserting validity or trust;
- inventory signature-material pairs and declared digest algorithms without
asserting validity or trust;
- extract bounded dependency declarations from package.json, Python requirements,
Maven metadata and JAR Class-Path, plus conventional licence-file evidence;
- flag missing relationship/manifest targets and unreferenced embedded media;
- preview bounded text, raster images, audio and video, and download a strictly
re-read entry;
- compare two package inventories by path, kind, size, compressed size and CRC;
- compare two packages using bounded, on-demand SHA-256 of verified
decompressed entries; matching metadata/CRC alone remains explicitly
unverified;
- summarize safe bounded JSON, XML, manifest and text differences without
rendering or executing package content;
- export an inert JSON inventory report.
- explicitly hand a verified entry to the browser/OS share surface where
supported, with verified download-and-reopen as the local-only fallback.
All processing happens in the browser. The app has no telemetry, remote parser,
CDN assets or automatic network lookups.
@@ -42,11 +51,14 @@ for the complete verification suite.
Package Tools is an inspection aid, not a renderer for office/ebook content,
an APK resource decoder, a Java class analyser, an extension sandbox, or a
signature verifier. It never establishes authenticity or publisher trust.
Dependency declarations are not resolved or vulnerability-checked, and licence
evidence is not a legal interpretation. A chosen OS share target can upload a
file even though Package Tools itself never does.
Encrypted entries, split archives, unsafe paths, unsupported compression, and
entries beyond the configured byte budgets remain inventory-only.
APK v2/v3/v4 signing blocks live outside normal ZIP entries and are not decoded
in v0.1. Chrome CRX wrapper headers are not a guaranteed input format; use an
in v0.2. Chrome CRX wrapper headers are not a guaranteed input format; use an
unpacked ZIP-form extension package when needed.
## Licence
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity
- Project: Package Tools
- Version: 0.1.0
- Version: 0.2.0
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
- Licence: GPL-3.0-or-later
- Toolbox app id: `de.add-ideas.package-tools`
+2 -1
View File
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
artifact at build time.
- React and React DOM — MIT
- add·ideas Toolbox Contract, Shell React and Helpers — GPL-3.0-or-later
- add·ideas Toolbox Contract and Shell React 0.3.0 — Apache-2.0
- add·ideas Toolbox Helpers 0.2.0 — GPL-3.0-or-later
- zip.js — BSD-3-Clause
- @xmldom/xmldom — MIT
+13 -2
View File
@@ -7,12 +7,23 @@ The React workbench is a thin UI over four bounded layers:
2. `src/package/analyze.ts` selects at most 512 metadata files / 16 MiB,
determines a package adapter, parses static XML or bounded JSON, and builds
references and diagnostics.
3. Tree and comparison modules create deterministic projections without
reading entry payloads.
3. Tree comparison first reports metadata-only candidates as unverified. On an
explicit user action it reopens up to 512 comparable entries within a 128 MiB
expanded-byte budget, verifies container integrity and hashes decompressed
bytes with SHA-256. Differing small JSON, static XML, manifest and text files
receive bounded semantic summaries; DTDs/entities remain rejected.
4. The preview path reopens the package and performs strict CRC/overlap checks
for one bounded entry. Markup is text-only; only browser-supported raster
image/audio/video blobs receive local object URLs.
Supply-chain inspection is declaration-only: bounded package.json,
requirements, Maven/JAR and conventional licence paths produce evidence rows
without registry access, resolution, vulnerability lookup or legal inference.
Signature inventory correlates companion `.SF`/`.RSA`/`.DSA`/`.EC` entries and
reports declared digest algorithm names, but never verifies signed bytes or a
certificate chain. Explicit entry handoff uses the browser's user-mediated Web
Share surface; verified download is retained as the predictable fallback.
The UI lazy-loads the workbench beneath the shared toolbox shell. The app uses
relative URLs so it works under nested portal paths, and a service worker caches
same-origin resources after first use.
+1 -1
View File
@@ -4,7 +4,7 @@ Files remain in browser memory and are never uploaded. Package Tools performs
no telemetry, DNS lookup, external relationship fetch, certificate lookup or
signature validation. Export occurs only after an explicit button press.
Limits in v0.1 include 512 MiB source and aggregate declared expansion, 20,000
Limits in v0.2 include 512 MiB source and aggregate declared expansion, 20,000
entries, 256 MiB per entry, 200:1 compression ratio, 2 MiB per metadata entry,
16 MiB total inspected metadata, and 512 metadata files. UI rendering is capped
at 5,000 tree/comparison rows while diagnostics cover the bounded inventory.
+20 -20
View File
@@ -1,24 +1,24 @@
{
"name": "package-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "package-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",
"@xmldom/xmldom": "0.9.12",
"@zip.js/zip.js": "2.9.0",
"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",
@@ -45,24 +45,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"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",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"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",
@@ -70,13 +70,13 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"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"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "package-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect and compare compound ZIP packages 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",
"@xmldom/xmldom": "0.9.12",
"@zip.js/zip.js": "2.9.0",
"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"] },
},
],
});
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 0.2.0 - 2026-09-02
- Replaced CRC/size equality with bounded on-demand decompressed SHA-256 and
safe semantic text, JSON, XML and manifest diffs.
- Added bounded dependency and licence declarations, signature-companion and
declared-digest diagnostics, plus verified-entry Web Share handoff.
- Declared accurate package input, report/extracted-file output and optional
browser capability profiles for Toolbox handoff.
## 0.1.0 - 2026-09-01
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
+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 ---
+16 -4
View File
@@ -6,7 +6,7 @@ bounded, format-aware interpretation for EPUB, OOXML (`.docx`, `.xlsx`,
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
extension packages.
Version 0.1.0 can:
Version 0.2.0 can:
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
- reject traversal, ambiguous, colliding and special-entry paths;
@@ -14,12 +14,21 @@ Version 0.1.0 can:
- interpret OOXML content types and internal/external relationships;
- inspect EPUB container/package metadata and manifest/spine consistency;
- inspect ODF manifests, JAR manifests and WebExtension manifests;
- inventory known signature-material files without asserting validity or trust;
- inventory signature-material pairs and declared digest algorithms without
asserting validity or trust;
- extract bounded dependency declarations from package.json, Python requirements,
Maven metadata and JAR Class-Path, plus conventional licence-file evidence;
- flag missing relationship/manifest targets and unreferenced embedded media;
- preview bounded text, raster images, audio and video, and download a strictly
re-read entry;
- compare two package inventories by path, kind, size, compressed size and CRC;
- compare two packages using bounded, on-demand SHA-256 of verified
decompressed entries; matching metadata/CRC alone remains explicitly
unverified;
- summarize safe bounded JSON, XML, manifest and text differences without
rendering or executing package content;
- export an inert JSON inventory report.
- explicitly hand a verified entry to the browser/OS share surface where
supported, with verified download-and-reopen as the local-only fallback.
All processing happens in the browser. The app has no telemetry, remote parser,
CDN assets or automatic network lookups.
@@ -42,11 +51,14 @@ for the complete verification suite.
Package Tools is an inspection aid, not a renderer for office/ebook content,
an APK resource decoder, a Java class analyser, an extension sandbox, or a
signature verifier. It never establishes authenticity or publisher trust.
Dependency declarations are not resolved or vulnerability-checked, and licence
evidence is not a legal interpretation. A chosen OS share target can upload a
file even though Package Tools itself never does.
Encrypted entries, split archives, unsafe paths, unsupported compression, and
entries beyond the configured byte budgets remain inventory-only.
APK v2/v3/v4 signing blocks live outside normal ZIP entries and are not decoded
in v0.1. Chrome CRX wrapper headers are not a guaranteed input format; use an
in v0.2. Chrome CRX wrapper headers are not a guaranteed input format; use an
unpacked ZIP-form extension package when needed.
## Licence
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity
- Project: Package Tools
- Version: 0.1.0
- Version: 0.2.0
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
- Licence: GPL-3.0-or-later
- Toolbox app id: `de.add-ideas.package-tools`
+2 -1
View File
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
artifact at build time.
- React and React DOM — MIT
- add·ideas Toolbox Contract, Shell React and Helpers — GPL-3.0-or-later
- add·ideas Toolbox Contract and Shell React 0.3.0 — Apache-2.0
- add·ideas Toolbox Helpers 0.2.0 — GPL-3.0-or-later
- zip.js — BSD-3-Clause
- @xmldom/xmldom — MIT
+13 -2
View File
@@ -7,12 +7,23 @@ The React workbench is a thin UI over four bounded layers:
2. `src/package/analyze.ts` selects at most 512 metadata files / 16 MiB,
determines a package adapter, parses static XML or bounded JSON, and builds
references and diagnostics.
3. Tree and comparison modules create deterministic projections without
reading entry payloads.
3. Tree comparison first reports metadata-only candidates as unverified. On an
explicit user action it reopens up to 512 comparable entries within a 128 MiB
expanded-byte budget, verifies container integrity and hashes decompressed
bytes with SHA-256. Differing small JSON, static XML, manifest and text files
receive bounded semantic summaries; DTDs/entities remain rejected.
4. The preview path reopens the package and performs strict CRC/overlap checks
for one bounded entry. Markup is text-only; only browser-supported raster
image/audio/video blobs receive local object URLs.
Supply-chain inspection is declaration-only: bounded package.json,
requirements, Maven/JAR and conventional licence paths produce evidence rows
without registry access, resolution, vulnerability lookup or legal inference.
Signature inventory correlates companion `.SF`/`.RSA`/`.DSA`/`.EC` entries and
reports declared digest algorithm names, but never verifies signed bytes or a
certificate chain. Explicit entry handoff uses the browser's user-mediated Web
Share surface; verified download is retained as the predictable fallback.
The UI lazy-loads the workbench beneath the shared toolbox shell. The app uses
relative URLs so it works under nested portal paths, and a service worker caches
same-origin resources after first use.
+1 -1
View File
@@ -4,7 +4,7 @@ Files remain in browser memory and are never uploaded. Package Tools performs
no telemetry, DNS lookup, external relationship fetch, certificate lookup or
signature validation. Export occurs only after an explicit button press.
Limits in v0.1 include 512 MiB source and aggregate declared expansion, 20,000
Limits in v0.2 include 512 MiB source and aggregate declared expansion, 20,000
entries, 256 MiB per entry, 200:1 compression ratio, 2 MiB per metadata entry,
16 MiB total inspected metadata, and 512 metadata files. UI rendering is capped
at 5,000 tree/comparison rows while diagnostics cover the bounded inventory.
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "package-tools-v0.1.0";
const CACHE = "package-tools-v0.2.0";
const APP = [
"./",
"./index.html",
+50 -3
View File
@@ -3,12 +3,21 @@
"schemaVersion": 1,
"id": "de.add-ideas.package-tools",
"name": "Package Tools",
"version": "0.1.0",
"description": "Inspect compound packages locally.",
"version": "0.2.0",
"description": "Inspect, compare and extract compound packages locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "developer", "forensics"],
"tags": ["zip", "epub", "ooxml", "odf", "jar", "apk", "extension"],
"tags": [
"zip",
"epub",
"ooxml",
"odf",
"jar",
"apk",
"dependencies",
"signatures"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +30,44 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/zip",
"extensions": [".zip", ".jar", ".apk", ".xpi"]
},
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{
"mediaType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extensions": [".docx"]
},
{
"mediaType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extensions": [".xlsx"]
},
{
"mediaType": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"extensions": [".pptx"]
},
{
"mediaType": "application/vnd.oasis.opendocument.text",
"extensions": [".odt"]
},
{
"mediaType": "application/vnd.oasis.opendocument.spreadsheet",
"extensions": [".ods"]
},
{
"mediaType": "application/vnd.oasis.opendocument.presentation",
"extensions": [".odp"]
}
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/octet-stream", "extensions": [] }
]
},
"capabilities": { "required": [], "optional": ["web-crypto", "web-share"] },
"privacy": {
"processing": "local",
"fileUploads": true,
+267 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
stableStringify,
triggerBlobDownload,
@@ -7,8 +7,14 @@ import { formatBytes } from "../archive/limits";
import type { ArchiveEntryRecord } from "../archive/types";
import { analyzePackage, entryBlob, previewEntry } from "../package/analyze";
import { compareInventories } from "../package/compare";
import { verifyPackageContents } from "../package/content-compare";
import { buildPackageTree } from "../package/tree";
import type { EntryPreview, PackageDocument, TreeNode } from "../package/types";
import type {
ContentComparisonEvidenceMap,
EntryPreview,
PackageDocument,
TreeNode,
} from "../package/types";
type Tab =
| "overview"
@@ -17,6 +23,7 @@ type Tab =
| "relationships"
| "diagnostics"
| "signatures"
| "supply-chain"
| "compare";
export function Workbench() {
@@ -85,10 +92,39 @@ export function Workbench() {
}
}
async function shareEntry(entry: ArchiveEntryRecord) {
if (!active) return;
try {
const blob = await entryBlob(active, entry);
const file = new File(
[blob],
entry.path.split("/").at(-1) || "package-entry.bin",
{ type: blob.type },
);
if (!navigator.share || !navigator.canShare?.({ files: [file] }))
throw new Error(
"This browser has no file handoff surface. Download the verified entry and open it explicitly in the target tool.",
);
await navigator.share({
files: [file],
title: `Open ${file.name} with another local application`,
});
setStatus(
`Handed ${entry.path} to the browser's explicit share/open-with surface.`,
);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setStatus("Open-with handoff cancelled.");
return;
}
setStatus(error instanceof Error ? error.message : String(error));
}
}
function exportReport() {
if (!active) return;
const report = {
application: "Package Tools 0.1.0",
application: "Package Tools 0.2.0",
generatedAt: new Date().toISOString(),
package: {
name: active.name,
@@ -110,6 +146,8 @@ export function Workbench() {
diagnostics: active.diagnostics,
relationships: active.relationships,
signatures: active.signatures,
dependencies: active.dependencies,
licenses: active.licenses,
boundary:
"Signature files are inventoried without cryptographic validation or trust claims.",
};
@@ -195,6 +233,7 @@ export function Workbench() {
["relationships", "Relationships"],
["diagnostics", "Diagnostics"],
["signatures", "Signatures"],
["supply-chain", "Dependencies & licences"],
...(documents.length > 1 ? [["compare", "Compare"]] : []),
] as [Tab, string][]
).map(([id, label]) => (
@@ -253,6 +292,7 @@ export function Workbench() {
preview={preview}
status={previewStatus}
onDownload={downloadEntry}
onShare={shareEntry}
/>
</section>
)}
@@ -266,8 +306,15 @@ export function Workbench() {
{active && tab === "signatures" && (
<SignaturesView document={active} />
)}
{active && tab === "supply-chain" && (
<SupplyChainView document={active} />
)}
{tab === "compare" && documents.length > 1 && (
<CompareView left={documents[0]!} right={documents[1]!} />
<CompareView
key={`${documents[0]!.source.name}:${documents[0]!.source.size}:${documents[0]!.source.lastModified}:${documents[1]!.source.name}:${documents[1]!.source.size}:${documents[1]!.source.lastModified}`}
left={documents[0]!}
right={documents[1]!}
/>
)}
</>
)}
@@ -422,12 +469,14 @@ function EntryInspector({
preview,
status,
onDownload,
onShare,
}: {
document: PackageDocument;
entry?: ArchiveEntryRecord;
preview?: EntryPreview;
status: string;
onDownload: (entry: ArchiveEntryRecord) => void;
onShare: (entry: ArchiveEntryRecord) => void;
}) {
return (
<aside className="panel inspector" aria-label="Entry inspector">
@@ -473,6 +522,18 @@ function EntryInspector({
>
Download verified entry
</button>
<button
type="button"
disabled={!entry.extractable}
onClick={() => void onShare(entry)}
>
Open/share verified entry
</button>
<p className="boundary">
Open-with uses the browser/OS share surface only after this click. A
target app may upload data; downloading and reopening in another
Toolbox module is the predictable local-only fallback.
</p>
{status && (
<p className="boundary" role="status">
{status}
@@ -667,6 +728,19 @@ function SignaturesView({ document }: { document: PackageDocument }) {
<li key={item.path}>
<strong>{item.format}</strong>
<code>{item.path}</code>
<span
className={`badge ${item.state === "unpaired" ? "warning" : "ok"}`}
>
{item.state.replaceAll("-", " ")}
</span>
{item.companions.length ? (
<p>Companions: {item.companions.join(", ")}</p>
) : null}
{item.declaredDigests.length ? (
<p>
Declared digest algorithms: {item.declaredDigests.join(", ")}
</p>
) : null}
<p>{item.note}</p>
</li>
))}
@@ -678,6 +752,79 @@ function SignaturesView({ document }: { document: PackageDocument }) {
);
}
function SupplyChainView({ document }: { document: PackageDocument }) {
return (
<section className="supply-grid">
<article className="panel">
<div className="section-heading">
<div>
<p className="eyebrow">Declarations, not resolution</p>
<h2>Dependencies</h2>
</div>
<span className="count">{document.dependencies.length}</span>
</div>
<p className="boundary">
Entries come from bundled package.json, requirements, Maven metadata
and JAR Class-Path declarations. They are not fetched or checked
against a vulnerability service.
</p>
{document.dependencies.length ? (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Version/range</th>
<th>Scope</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
{document.dependencies.slice(0, 10_000).map((item, index) => (
<tr key={`${item.source}-${item.name}-${index}`}>
<td>{item.name}</td>
<td>{item.version || "Not declared"}</td>
<td>{item.scope}</td>
<td>
<code>{item.source}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p>No supported dependency declarations were found.</p>
)}
</article>
<article className="panel">
<div className="section-heading">
<div>
<p className="eyebrow">Evidence, not legal advice</p>
<h2>Licence declarations and files</h2>
</div>
<span className="count">{document.licenses.length}</span>
</div>
{document.licenses.length ? (
<ul className="card-list">
{document.licenses.map((item, index) => (
<li key={`${item.source}-${index}`}>
<strong>{item.name}</strong>
<code>{item.source}</code>
<p>{item.note}</p>
</li>
))}
</ul>
) : (
<p>
No conventional licence file or supported declaration was found.
</p>
)}
</article>
</section>
);
}
function CompareView({
left,
right,
@@ -685,20 +832,97 @@ function CompareView({
left: PackageDocument;
right: PackageDocument;
}) {
const comparison = compareInventories(left, right);
const changed = comparison.filter((item) => item.status !== "same");
const [evidence, setEvidence] = useState<ContentComparisonEvidenceMap>({});
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState("");
const [error, setError] = useState("");
const controller = useRef<AbortController | undefined>(undefined);
useEffect(() => () => controller.current?.abort(), []);
const comparison = useMemo(
() => compareInventories(left, right, evidence),
[evidence, left, right],
);
const attention = comparison.filter((item) => item.status !== "same");
const verify = async () => {
controller.current?.abort();
const active = new AbortController();
controller.current = active;
setBusy(true);
setError("");
setProgress("Preparing bounded content comparison…");
try {
const result = await verifyPackageContents(
left,
right,
active.signal,
({ completed, total, path }) =>
setProgress(`${completed} / ${total} hashed · ${path.slice(0, 100)}`),
);
if (controller.current !== active) return;
setEvidence(result);
setProgress(
`Verified ${Object.values(result).filter((item) => item.leftSha256).length} comparable entries by decompressed SHA-256.`,
);
} catch (reason) {
if (
controller.current === active &&
!active.signal.aborted &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
reason instanceof Error ? reason.message : "Comparison failed.",
);
} finally {
if (controller.current === active) {
controller.current = undefined;
setBusy(false);
}
}
};
return (
<section className="panel">
<div className="section-heading">
<div>
<p className="eyebrow">Path, size and CRC inventory</p>
<p className="eyebrow">Decompressed content evidence</p>
<h2>Compare packages</h2>
</div>
<span className="count">{changed.length} differences</span>
<span className="count">{attention.length} require attention</span>
</div>
<p>
{left.name} {right.name}
</p>
<p className="boundary">
Matching sizes and CRC declarations are never treated as proof of equal
content. Verification re-reads bounded entries, checks ZIP integrity and
hashes decompressed bytes with SHA-256. Structured summaries are inert
and bounded.
</p>
<div className="button-row">
<button type="button" disabled={busy} onClick={() => void verify()}>
Verify comparable contents
</button>
{busy ? (
<button
type="button"
onClick={() => {
controller.current?.abort();
controller.current = undefined;
setBusy(false);
setProgress("Verification cancelled; prior evidence retained.");
}}
>
Cancel verification
</button>
) : null}
</div>
{progress ? <p role="status">{progress}</p> : null}
{error ? (
<p className="error" role="alert">
{error}
</p>
) : null}
<div className="table-scroll">
<table>
<thead>
@@ -707,10 +931,11 @@ function CompareView({
<th>Path</th>
<th>{left.name}</th>
<th>{right.name}</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
{changed.slice(0, 5000).map((item) => (
{attention.slice(0, 5000).map((item) => (
<tr key={item.path}>
<td>
<span
@@ -722,13 +947,44 @@ function CompareView({
<td className="wrap">{item.path}</td>
<td>{item.left ? formatBytes(item.left.size) : "—"}</td>
<td>{item.right ? formatBytes(item.right.size) : "—"}</td>
<td className="wrap">
{item.evidence?.leftSha256 && item.evidence.rightSha256 ? (
<>
<code>
{item.evidence.leftSha256.slice(0, 12)} {" "}
{item.evidence.rightSha256.slice(0, 12)}
</code>
{item.evidence.semantic ? (
<details>
<summary>{item.evidence.semantic.summary}</summary>
{item.evidence.semantic.differences.length ? (
<ul>
{item.evidence.semantic.differences.map(
(difference) => (
<li key={difference}>{difference}</li>
),
)}
</ul>
) : null}
</details>
) : null}
</>
) : (
(item.evidence?.skipped ??
(item.status === "unverified"
? "Not hashed yet"
: "Metadata proves a difference"))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{changed.length === 0 && (
<p className="success">The bounded inventories match.</p>
{attention.length === 0 && (
<p className="success">
Every comparable file has matching decompressed SHA-256 evidence.
</p>
)}
</section>
);
+189 -5
View File
@@ -12,9 +12,11 @@ import type {
EntryAnnotation,
EntryPreview,
PackageDiagnostic,
PackageDependency,
PackageDocument,
PackageKind,
PackageMetadata,
PackageLicense,
PackageRelationship,
PackageSignature,
} from "./types";
@@ -111,8 +113,10 @@ export async function analyzePackage(
else if (kind === "chrome-extension" || kind === "firefox-extension")
analyzeExtension(state, manifestValue, kind);
const { dependencies, licenses } = analyzeSupplyChain(state);
applyReferences(state);
const signatures = inventorySignatures(parsed.entries, kind);
const signatures = inventorySignatures(parsed.entries, kind, texts);
for (const signature of signatures)
state.annotations[entryByPath.get(signature.path)?.id ?? ""] = {
...(state.annotations[entryByPath.get(signature.path)?.id ?? ""] ?? {
@@ -134,6 +138,8 @@ export async function analyzePackage(
relationships: state.relationships,
metadata: state.metadata,
signatures,
dependencies,
licenses,
annotations: state.annotations,
sourceBytes: file.size,
expandedBytes: parsed.expandedBytes,
@@ -269,7 +275,13 @@ function isMetadataPath(path: string): boolean {
lower.endsWith(".xml") ||
lower.endsWith(".json") ||
lower.endsWith("manifest.mf") ||
lower.endsWith(".sf")
lower.endsWith(".sf") ||
lower === "package.json" ||
/(?:^|\/)requirements[^/]*\.txt$/u.test(lower) ||
/(?:^|\/)pom\.properties$/u.test(lower) ||
/(?:^|\/)(?:licen[cs]e|copying|notice|third[_-]party)(?:\.[^/]*)?$/u.test(
lower,
)
);
}
@@ -715,7 +727,7 @@ function analyzeApk(state: AnalysisState): void {
diagnostic(
"info",
"APK_BINARY_MANIFEST",
"AndroidManifest.xml and resources.arsc are binary formats; v0.1 inventories them without decoding.",
"AndroidManifest.xml and resources.arsc are binary formats; v0.2 inventories them without decoding.",
),
);
state.diagnostics.push(
@@ -834,9 +846,149 @@ function parseExtensionManifest(
}
}
function analyzeSupplyChain(state: AnalysisState): {
dependencies: PackageDependency[];
licenses: PackageLicense[];
} {
const dependencies: PackageDependency[] = [];
const licenses: PackageLicense[] = [];
const dependencyKeys = new Set<string>();
const licenseKeys = new Set<string>();
const addDependency = (item: PackageDependency) => {
const key = `${item.scope}\0${item.name}\0${item.version ?? ""}\0${item.source}`;
if (dependencyKeys.has(key) || dependencies.length >= 10_000) return;
dependencyKeys.add(key);
dependencies.push({
...item,
name: item.name.slice(0, 300),
version: item.version?.slice(0, 300),
});
};
const addLicense = (item: PackageLicense) => {
const key = `${item.name}\0${item.source}`;
if (licenseKeys.has(key) || licenses.length >= 512) return;
licenseKeys.add(key);
licenses.push(item);
};
for (const entry of state.entries) {
if (
entry.kind === "file" &&
/(?:^|\/)(?:licen[cs]e|copying|notice|third[_-]party)(?:\.[^/]*)?$/iu.test(
entry.path,
)
)
addLicense({
name: entry.path.split("/").at(-1) ?? "Licence file",
source: entry.path,
note: "Declared file only; legal meaning and compatibility are not assessed.",
});
}
for (const [path, source] of state.texts) {
const lower = path.toLowerCase();
if (lower === "package.json" || lower.endsWith("/package.json")) {
try {
const value = safeJsonParse(source, {
maxTextChars: MAX_METADATA_ENTRY,
maxDepth: 32,
maxNodes: 100_000,
});
if (isObject(value)) {
for (const [field, scope] of [
["dependencies", "runtime"],
["devDependencies", "development"],
["peerDependencies", "peer"],
["optionalDependencies", "optional"],
] as const) {
const group = value[field];
if (!isObject(group)) continue;
for (const [name, version] of Object.entries(group))
if (typeof version === "string")
addDependency({ name, version, scope, source: path });
}
const declaredLicense = value.license;
if (typeof declaredLicense === "string")
addLicense({
name: declaredLicense.slice(0, 300),
source: path,
note: "Package metadata declaration; licence text was not interpreted.",
});
}
} catch (error) {
state.diagnostics.push(
diagnostic("warning", "PACKAGE_JSON", message(error), path),
);
}
} else if (/(?:^|\/)requirements[^/]*\.txt$/u.test(lower)) {
for (const raw of source.split(/\r?\n/u).slice(0, 20_000)) {
const line = raw.trim();
if (!line || line.startsWith("#") || line.startsWith("-")) continue;
const match = /^([A-Za-z0-9_.-]+)\s*(.*)$/u.exec(line);
if (match)
addDependency({
name: match[1]!,
...(match[2] ? { version: match[2].slice(0, 300) } : {}),
scope: "Python requirement",
source: path,
});
}
} else if (/(?:^|\/)pom\.properties$/u.test(lower)) {
const fields = new Map<string, string>();
for (const line of source.split(/\r?\n/u)) {
const separator = line.indexOf("=");
if (separator > 0)
fields.set(
line.slice(0, separator).trim(),
line.slice(separator + 1).trim(),
);
}
const artifact = fields.get("artifactId");
if (artifact)
addDependency({
name: `${fields.get("groupId") ?? "unknown"}:${artifact}`,
...(fields.get("version") ? { version: fields.get("version") } : {}),
scope: "bundled Maven component",
source: path,
});
}
}
const jarManifest = state.texts.get("META-INF/MANIFEST.MF");
if (jarManifest) {
const unfolded = jarManifest.replace(/\r?\n ([^\r\n]*)/gu, "$1");
const classPath = /^Class-Path:\s*(.+)$/imu.exec(unfolded)?.[1];
for (const item of classPath?.trim().split(/\s+/u) ?? [])
if (item)
addDependency({
name: item,
scope: "JAR Class-Path declaration",
source: "META-INF/MANIFEST.MF",
});
const bundleLicense = /^Bundle-License:\s*(.+)$/imu.exec(unfolded)?.[1];
if (bundleLicense)
addLicense({
name: bundleLicense.slice(0, 300),
source: "META-INF/MANIFEST.MF",
note: "OSGi declaration only; no licence compatibility assessment.",
});
}
if (dependencies.length >= 10_000)
state.diagnostics.push(
diagnostic(
"warning",
"DEPENDENCY_LIMIT",
"Dependency evidence was capped at 10,000 declarations.",
),
);
return { dependencies, licenses };
}
function inventorySignatures(
entries: ArchiveEntryRecord[],
kind: PackageKind,
texts: Map<string, string>,
): PackageSignature[] {
const output: PackageSignature[] = [];
for (const entry of entries) {
@@ -855,12 +1007,44 @@ function inventorySignatures(
: "JAR signature material";
else if (/^META-INF\/(?:mozilla|cose)\.(?:rsa|sf|manifest)$/iu.test(path))
format = "Extension signature material";
if (format)
if (format) {
const base = path.replace(/\.(?:SF|RSA|DSA|EC)$/iu, "");
const companions = entries
.map((candidate) => candidate.path)
.filter(
(candidate) =>
candidate !== path &&
candidate.replace(/\.(?:SF|RSA|DSA|EC)$/iu, "") === base &&
/\.(?:SF|RSA|DSA|EC)$/iu.test(candidate),
);
const declaration = /\.SF$/iu.test(path)
? texts.get(path)
: texts.get(`${base}.SF`);
const declaredDigests = [
...new Set(
[
...(declaration?.matchAll(
/^([A-Za-z0-9-]+)-Digest(?:-Manifest)?:/gimu,
) ?? []),
].map((match) => match[1]!.toUpperCase()),
),
];
output.push({
path,
format,
note: "Presence only; authenticity, integrity and trust are not verified.",
state: /\.(?:SF|RSA|DSA|EC)$/iu.test(path)
? companions.length
? "complete-pair"
: "unpaired"
: "single-container",
companions,
declaredDigests,
note:
companions.length || !/\.(?:SF|RSA|DSA|EC)$/iu.test(path)
? "Structure inventoried; authenticity, integrity and trust are not verified."
: "Expected companion signature material was not found; no cryptographic validation was attempted.",
});
}
}
return output;
}
+21 -9
View File
@@ -1,8 +1,13 @@
import type { PackageDocument, InventoryComparison } from "./types";
import type {
ContentComparisonEvidenceMap,
InventoryComparison,
PackageDocument,
} from "./types";
export function compareInventories(
left: PackageDocument,
right: PackageDocument,
evidence: ContentComparisonEvidenceMap = {},
): InventoryComparison[] {
const leftMap = new Map(left.entries.map((entry) => [entry.path, entry]));
const rightMap = new Map(right.entries.map((entry) => [entry.path, entry]));
@@ -11,17 +16,24 @@ export function compareInventories(
.map((path) => {
const leftEntry = leftMap.get(path);
const rightEntry = rightMap.get(path);
const content = evidence[path];
let status: InventoryComparison["status"];
if (!leftEntry) status = "only-right";
else if (!rightEntry) status = "only-left";
else if (leftEntry.kind !== rightEntry.kind) status = "kind-changed";
else if (
leftEntry.size === rightEntry.size &&
leftEntry.crc32 === rightEntry.crc32 &&
leftEntry.compressedSize === rightEntry.compressedSize
)
status = "same";
else status = "changed";
return { path, status, left: leftEntry, right: rightEntry };
else if (leftEntry.kind === "directory") status = "same";
else if (leftEntry.kind !== "file") status = "unverified";
else if (content?.leftSha256 && content.rightSha256)
status =
content.leftSha256 === content.rightSha256 ? "same" : "changed";
else if (leftEntry.size !== rightEntry.size) status = "changed";
else status = "unverified";
return {
path,
status,
left: leftEntry,
right: rightEntry,
...(content ? { evidence: content } : {}),
};
});
}
+370
View File
@@ -0,0 +1,370 @@
import {
decodeText,
safeJsonParse,
stableStringify,
type JsonValue,
} from "@add-ideas/toolbox-helpers";
import { throwIfAborted } from "../archive/limits";
import { readZipEntries } from "../archive/zip";
import type { ArchiveEntryRecord } from "../archive/types";
import { parseStaticXml } from "./xml";
import type {
ContentComparisonEvidence,
ContentComparisonEvidenceMap,
PackageDocument,
SemanticDifference,
} from "./types";
export const MAX_HASH_ENTRY_BYTES = 16 * 1024 * 1024;
export const MAX_HASH_TOTAL_BYTES = 128 * 1024 * 1024;
export const MAX_HASH_PAIRS = 512;
const MAX_SEMANTIC_BYTES = 2 * 1024 * 1024;
const MAX_DIFFERENCES = 50;
export interface ContentVerificationProgress {
completed: number;
total: number;
path: string;
}
export async function verifyPackageContents(
left: PackageDocument,
right: PackageDocument,
signal?: AbortSignal,
onProgress?: (update: ContentVerificationProgress) => void,
): Promise<ContentComparisonEvidenceMap> {
const rightEntries = new Map(
right.entries.map((entry) => [entry.path, entry]),
);
const candidates = left.entries
.map((entry) => [entry, rightEntries.get(entry.path)] as const)
.filter((pair): pair is readonly [ArchiveEntryRecord, ArchiveEntryRecord] =>
Boolean(
pair[1] &&
pair[0].kind === "file" &&
pair[1].kind === "file" &&
pair[0].extractable &&
pair[1].extractable,
),
);
const evidence: ContentComparisonEvidenceMap = {};
let budget = 0;
let selected = 0;
for (const [leftEntry, rightEntry] of candidates) {
const bytes = leftEntry.size + rightEntry.size;
if (
leftEntry.size > MAX_HASH_ENTRY_BYTES ||
rightEntry.size > MAX_HASH_ENTRY_BYTES
) {
evidence[leftEntry.path] = {
skipped: "Entry exceeds the 16 MiB on-demand hash limit.",
};
} else if (
selected >= MAX_HASH_PAIRS ||
budget + bytes > MAX_HASH_TOTAL_BYTES
) {
evidence[leftEntry.path] = {
skipped:
"Comparison reached its 512-entry / 128 MiB expanded-byte budget.",
};
} else {
selected += 1;
budget += bytes;
}
}
let completed = 0;
for (const [leftEntry, rightEntry] of candidates) {
if (evidence[leftEntry.path]?.skipped) continue;
throwIfAborted(signal);
onProgress?.({ completed, total: selected, path: leftEntry.path });
const [leftBytes, rightBytes] = await Promise.all([
readOne(left, leftEntry, signal),
readOne(right, rightEntry, signal),
]);
throwIfAborted(signal);
const [leftSha256, rightSha256] = await Promise.all([
sha256(leftBytes),
sha256(rightBytes),
]);
const item: ContentComparisonEvidence = { leftSha256, rightSha256 };
if (
leftSha256 !== rightSha256 &&
leftBytes.length <= MAX_SEMANTIC_BYTES &&
rightBytes.length <= MAX_SEMANTIC_BYTES
) {
const mimeType =
left.annotations[leftEntry.id]?.mimeType ??
right.annotations[rightEntry.id]?.mimeType;
item.semantic = compareSemanticEntry(
leftEntry.path,
leftBytes,
rightBytes,
mimeType,
);
}
evidence[leftEntry.path] = item;
completed += 1;
onProgress?.({ completed, total: selected, path: leftEntry.path });
}
return evidence;
}
async function readOne(
document: PackageDocument,
entry: ArchiveEntryRecord,
signal?: AbortSignal,
): Promise<Uint8Array> {
const result = await readZipEntries(
document.source,
[entry.sourceIndex],
MAX_HASH_ENTRY_BYTES,
signal,
);
const value = result.get(entry.sourceIndex);
if (!value) throw new Error(`Could not read ${entry.path} for comparison.`);
return value;
}
async function sha256(bytes: Uint8Array): Promise<string> {
const copy = Uint8Array.from(bytes);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", copy));
return Array.from(digest, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
}
export function compareSemanticEntry(
path: string,
leftBytes: Uint8Array,
rightBytes: Uint8Array,
mimeType?: string,
): SemanticDifference | undefined {
const lower = path.toLowerCase();
const kind =
lower.endsWith(".json") || /json/u.test(mimeType ?? "")
? "json"
: /(?:\.xml|\.rels|\.opf|\.xhtml|\.svg)$/u.test(lower) ||
/xml/u.test(mimeType ?? "")
? "xml"
: /(?:^|\/)meta-inf\/manifest\.mf$/u.test(lower)
? "manifest"
: /^(?:text\/)/u.test(mimeType ?? "") ||
/(?:\.txt|\.csv|\.css|\.js|\.md|^mimetype$)$/u.test(lower)
? "text"
: undefined;
if (!kind) return undefined;
try {
const left = decodeText(leftBytes, "utf-8", false).replace(/^\uFEFF/u, "");
const right = decodeText(rightBytes, "utf-8", false).replace(
/^\uFEFF/u,
"",
);
if (kind === "json") return compareJson(left, right);
if (kind === "xml") return compareXml(left, right);
if (kind === "manifest") return compareManifest(left, right);
return compareText(left, right);
} catch (reason) {
return {
kind,
equivalent: false,
summary: "Content differs; semantic comparison could not be completed.",
differences: [
reason instanceof Error ? reason.message : "Text decoding failed.",
],
};
}
}
function compareJson(left: string, right: string): SemanticDifference {
const first = safeJsonParse(left, {
maxTextChars: MAX_SEMANTIC_BYTES,
maxDepth: 64,
maxNodes: 100_000,
});
const second = safeJsonParse(right, {
maxTextChars: MAX_SEMANTIC_BYTES,
maxDepth: 64,
maxNodes: 100_000,
});
const differences: string[] = [];
jsonDifferences(first, second, "$", differences);
const equivalent = stableStringify(first) === stableStringify(second);
return {
kind: "json",
equivalent,
summary: equivalent
? "JSON values are equivalent; only serialization differs."
: `${differences.length}${differences.length === MAX_DIFFERENCES ? "+" : ""} bounded JSON value differences.`,
differences,
};
}
function jsonDifferences(
left: JsonValue,
right: JsonValue,
path: string,
output: string[],
): void {
if (output.length >= MAX_DIFFERENCES || Object.is(left, right)) return;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length)
output.push(`${path}: array length ${left.length}${right.length}`);
for (
let index = 0;
index < Math.max(left.length, right.length);
index += 1
) {
if (output.length >= MAX_DIFFERENCES) return;
if (index >= left.length || index >= right.length)
output.push(
`${path}[${index}]: ${index < left.length ? "removed" : "added"}`,
);
else
jsonDifferences(
left[index]!,
right[index]!,
`${path}[${index}]`,
output,
);
}
return;
}
if (isRecord(left) && isRecord(right)) {
for (const key of [
...new Set([...Object.keys(left), ...Object.keys(right)]),
].sort()) {
if (output.length >= MAX_DIFFERENCES) return;
if (!(key in left) || !(key in right))
output.push(`${path}.${key}: ${key in left ? "removed" : "added"}`);
else jsonDifferences(left[key]!, right[key]!, `${path}.${key}`, output);
}
return;
}
output.push(`${path}: value changed`);
}
function isRecord(value: JsonValue): value is Record<string, JsonValue> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function compareXml(left: string, right: string): SemanticDifference {
const first = canonicalXml(left);
const second = canonicalXml(right);
const equivalent = first === second;
return {
kind: "xml",
equivalent,
summary: equivalent
? "XML trees are equivalent after attribute-order and indentation normalization."
: "XML tree structure or values differ.",
differences: equivalent ? [] : firstDifferenceLines(first, second),
};
}
interface XmlLikeNode {
nodeType: number;
nodeName: string;
nodeValue?: string | null;
attributes?: {
length: number;
item(index: number): { name: string; value: string } | null;
};
childNodes?: {
length: number;
item(index: number): XmlLikeNode | null;
};
}
function canonicalXml(source: string): string {
const document = parseStaticXml(source) as unknown as XmlLikeNode;
let visited = 0;
const walk = (node: XmlLikeNode): string => {
if (++visited > 100_000) throw new RangeError("XML tree is too large.");
if (node.nodeType === 1) {
const attributes: string[] = [];
for (let index = 0; index < (node.attributes?.length ?? 0); index += 1) {
const item = node.attributes?.item(index);
if (item) attributes.push(`${item.name}=${JSON.stringify(item.value)}`);
}
const children: string[] = [];
for (let index = 0; index < (node.childNodes?.length ?? 0); index += 1) {
const item = node.childNodes?.item(index);
if (item) {
const value = walk(item);
if (value) children.push(value);
}
}
return `<${node.nodeName} ${attributes.sort().join(" ")}>${children.join("")}</${node.nodeName}>`;
}
if (node.nodeType === 3 || node.nodeType === 4) {
const text = (node.nodeValue ?? "").replace(/\r\n?/gu, "\n");
return text.trim() ? JSON.stringify(text) : "";
}
if (node.nodeType === 9) {
const children: string[] = [];
for (let index = 0; index < (node.childNodes?.length ?? 0); index += 1) {
const item = node.childNodes?.item(index);
if (item) children.push(walk(item));
}
return children.join("");
}
return "";
};
return walk(document);
}
function compareManifest(left: string, right: string): SemanticDifference {
const first = canonicalManifest(left);
const second = canonicalManifest(right);
const equivalent = first === second;
return {
kind: "manifest",
equivalent,
summary: equivalent
? "Manifest fields are equivalent after line folding and ordering normalization."
: "Manifest fields differ.",
differences: equivalent ? [] : firstDifferenceLines(first, second),
};
}
function canonicalManifest(source: string): string {
const unfolded = source.replace(/\r\n?/gu, "\n").replace(/\n /gu, "");
return unfolded
.split(/\n\n+/u)
.map((section) => section.split("\n").filter(Boolean).sort().join("\n"))
.sort()
.join("\n\n");
}
function compareText(left: string, right: string): SemanticDifference {
const first = left.replace(/\r\n?/gu, "\n");
const second = right.replace(/\r\n?/gu, "\n");
const equivalent = first === second;
return {
kind: "text",
equivalent,
summary: equivalent
? "Text is equivalent after line-ending normalization."
: "Text lines differ.",
differences: equivalent ? [] : firstDifferenceLines(first, second),
};
}
function firstDifferenceLines(left: string, right: string): string[] {
const first = left.split("\n");
const second = right.split("\n");
const output: string[] = [];
for (
let index = 0;
index < Math.max(first.length, second.length);
index += 1
) {
if (first[index] === second[index]) continue;
output.push(`Line ${index + 1} differs.`);
if (output.length >= 10) break;
}
if (first.length !== second.length)
output.unshift(`Line count ${first.length}${second.length}.`);
return output;
}
+45 -1
View File
@@ -43,6 +43,22 @@ export interface PackageSignature {
path: string;
format: string;
note: string;
state: "complete-pair" | "unpaired" | "single-container";
companions: string[];
declaredDigests: string[];
}
export interface PackageDependency {
name: string;
version?: string;
scope: string;
source: string;
}
export interface PackageLicense {
name: string;
source: string;
note: string;
}
export interface EntryAnnotation {
@@ -62,6 +78,8 @@ export interface PackageDocument {
relationships: PackageRelationship[];
metadata: PackageMetadata[];
signatures: PackageSignature[];
dependencies: PackageDependency[];
licenses: PackageLicense[];
annotations: Record<string, EntryAnnotation>;
sourceBytes: number;
expandedBytes: number;
@@ -71,11 +89,37 @@ export interface PackageDocument {
export interface InventoryComparison {
path: string;
status: "same" | "changed" | "only-left" | "only-right" | "kind-changed";
status:
| "same"
| "changed"
| "unverified"
| "only-left"
| "only-right"
| "kind-changed";
left?: ArchiveEntryRecord;
right?: ArchiveEntryRecord;
evidence?: ContentComparisonEvidence;
}
export interface SemanticDifference {
kind: "json" | "xml" | "manifest" | "text";
equivalent: boolean;
summary: string;
differences: string[];
}
export interface ContentComparisonEvidence {
leftSha256?: string;
rightSha256?: string;
semantic?: SemanticDifference;
skipped?: string;
}
export type ContentComparisonEvidenceMap = Record<
string,
ContentComparisonEvidence
>;
export interface TreeNode {
name: string;
path: string;
+8
View File
@@ -159,6 +159,11 @@ td small {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.supply-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.8fr);
gap: 1rem;
}
.summary-card {
grid-column: span 2;
}
@@ -435,6 +440,9 @@ th {
.split-view {
grid-template-columns: 1fr;
}
.supply-grid {
grid-template-columns: 1fr;
}
.inspector {
position: static;
max-height: none;
+53 -3
View File
@@ -3,12 +3,21 @@
"schemaVersion": 1,
"id": "de.add-ideas.package-tools",
"name": "Package Tools",
"version": "0.1.0",
"description": "Inspect compound packages locally.",
"version": "0.2.0",
"description": "Inspect, compare and extract compound packages locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "developer", "forensics"],
"tags": ["zip", "epub", "ooxml", "odf", "jar", "apk", "extension"],
"tags": [
"zip",
"epub",
"ooxml",
"odf",
"jar",
"apk",
"dependencies",
"signatures"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +30,47 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/zip",
"extensions": [".zip", ".jar", ".apk", ".xpi"]
},
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{
"mediaType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extensions": [".docx"]
},
{
"mediaType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extensions": [".xlsx"]
},
{
"mediaType": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"extensions": [".pptx"]
},
{
"mediaType": "application/vnd.oasis.opendocument.text",
"extensions": [".odt"]
},
{
"mediaType": "application/vnd.oasis.opendocument.spreadsheet",
"extensions": [".ods"]
},
{
"mediaType": "application/vnd.oasis.opendocument.presentation",
"extensions": [".odp"]
}
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/octet-stream", "extensions": [] }
]
},
"capabilities": {
"required": [],
"optional": ["web-crypto", "web-share"]
},
"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";
+10 -1
View File
@@ -92,6 +92,15 @@ test("compares two local package inventories", async ({ page }) => {
await expect(
page.getByRole("row", { name: /changed word\/document\.xml/u }),
).toBeVisible();
await page
.getByRole("button", { name: "Verify comparable contents" })
.click();
await expect(
page.getByText(/Verified \d+ comparable entries/u),
).toBeVisible();
await expect(
page.getByRole("row", { name: /changed word\/document\.xml/u }),
).toContainText(/[0-9a-f]{12} [0-9a-f]{12}/u);
});
test("integrates help, theme, PWA identity and hardened headers", async ({
@@ -123,7 +132,7 @@ test("integrates help, theme, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/package/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.package-tools",
version: "0.1.0",
version: "0.2.0",
privacy: { processing: "local", telemetry: false },
});
});
+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/package/");
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);
});
+37 -1
View File
@@ -104,7 +104,19 @@ describe("compound package analysis", () => {
const jar = await analyzePackage(
archive("tool.jar", {
"META-INF/MANIFEST.MF": strToU8(
"Manifest-Version: 1.0\r\nMain-Class: example.Main\r\n",
"Manifest-Version: 1.0\r\nMain-Class: example.Main\r\nClass-Path: lib/a.jar lib/b.jar\r\n",
),
"META-INF/LOCAL.SF": strToU8(
"Signature-Version: 1.0\r\nSHA-256-Digest-Manifest: abc\r\n",
),
"META-INF/LOCAL.RSA": new Uint8Array([1, 2, 3]),
"META-INF/LICENSE.txt": strToU8("Example licence evidence"),
"package.json": strToU8(
JSON.stringify({
dependencies: { alpha: "^1.2.0" },
devDependencies: { beta: "2.0.0" },
license: "MIT",
}),
),
"example/Main.class": new Uint8Array([0xca, 0xfe, 0xba, 0xbe]),
}),
@@ -115,6 +127,30 @@ describe("compound package analysis", () => {
name: "Main-Class",
value: "example.Main",
});
expect(jar.dependencies).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "alpha", scope: "runtime" }),
expect.objectContaining({
name: "lib/a.jar",
scope: "JAR Class-Path declaration",
}),
]),
);
expect(jar.licenses).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "MIT", source: "package.json" }),
expect.objectContaining({ source: "META-INF/LICENSE.txt" }),
]),
);
expect(jar.signatures).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "META-INF/LOCAL.SF",
state: "complete-pair",
declaredDigests: ["SHA-256"],
}),
]),
);
});
it("adapts APK and both WebExtension identities with bounded inventories", async () => {
+17 -1
View File
@@ -45,7 +45,23 @@ describe("inventory comparison", () => {
changed: "changed",
left: "only-left",
right: "only-right",
same: "same",
same: "unverified",
});
});
it("only calls file contents equal after matching SHA-256 evidence", () => {
const left = document([entry("same-size", 4, "declared")]);
const right = document([entry("same-size", 4, "declared")]);
expect(compareInventories(left, right)[0]?.status).toBe("unverified");
expect(
compareInventories(left, right, {
"same-size": { leftSha256: "abc", rightSha256: "abc" },
})[0]?.status,
).toBe("same");
expect(
compareInventories(left, right, {
"same-size": { leftSha256: "abc", rightSha256: "def" },
})[0]?.status,
).toBe("changed");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { strToU8, zipSync } from "fflate";
import { analyzePackage } from "../../src/package/analyze";
import { compareSemanticEntry } from "../../src/package/content-compare";
import { verifyPackageContents } from "../../src/package/content-compare";
const bytes = (value: string) => new TextEncoder().encode(value);
describe("bounded semantic package comparison", () => {
it("hashes verified decompressed bytes instead of ZIP representation", async () => {
const contents = { "same.txt": strToU8("same decompressed value") };
const left = await analyzePackage(
new File([zipSync(contents, { level: 0 })], "left.zip"),
);
const right = await analyzePackage(
new File([zipSync(contents, { level: 9 })], "right.zip"),
);
const evidence = await verifyPackageContents(left, right);
expect(evidence["same.txt"]?.leftSha256).toMatch(/^[0-9a-f]{64}$/u);
expect(evidence["same.txt"]?.leftSha256).toBe(
evidence["same.txt"]?.rightSha256,
);
});
it("recognizes equivalent JSON serialization and reports value paths", () => {
expect(
compareSemanticEntry(
"manifest.json",
bytes('{"name":"demo","items":[1,2]}'),
bytes('{\n "items": [1, 2], "name": "demo"\n}'),
),
).toMatchObject({ kind: "json", equivalent: true });
expect(
compareSemanticEntry(
"manifest.json",
bytes('{"name":"before"}'),
bytes('{"name":"after"}'),
)?.differences,
).toContain("$.name: value changed");
});
it("normalizes safe XML attributes but rejects active declarations", () => {
expect(
compareSemanticEntry(
"content.xml",
bytes('<root a="1" b="2"><item>value</item></root>'),
bytes('<root b="2" a="1">\n<item>value</item>\n</root>'),
),
).toMatchObject({ kind: "xml", equivalent: true });
expect(
compareSemanticEntry(
"content.xml",
bytes("<!DOCTYPE root><root/>"),
bytes("<root/>"),
)?.summary,
).toMatch(/could not/u);
});
});