Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c7a8c372 |
@@ -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
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
|
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ bounded, format-aware interpretation for EPUB, OOXML (`.docx`, `.xlsx`,
|
|||||||
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
|
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
|
||||||
extension packages.
|
extension packages.
|
||||||
|
|
||||||
Version 0.1.0 can:
|
Version 0.2.0 can:
|
||||||
|
|
||||||
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
|
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
|
||||||
- reject traversal, ambiguous, colliding and special-entry paths;
|
- 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;
|
- interpret OOXML content types and internal/external relationships;
|
||||||
- inspect EPUB container/package metadata and manifest/spine consistency;
|
- inspect EPUB container/package metadata and manifest/spine consistency;
|
||||||
- inspect ODF manifests, JAR manifests and WebExtension manifests;
|
- 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;
|
- flag missing relationship/manifest targets and unreferenced embedded media;
|
||||||
- preview bounded text, raster images, audio and video, and download a strictly
|
- preview bounded text, raster images, audio and video, and download a strictly
|
||||||
re-read entry;
|
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.
|
- 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,
|
All processing happens in the browser. The app has no telemetry, remote parser,
|
||||||
CDN assets or automatic network lookups.
|
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,
|
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
|
an APK resource decoder, a Java class analyser, an extension sandbox, or a
|
||||||
signature verifier. It never establishes authenticity or publisher trust.
|
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
|
Encrypted entries, split archives, unsafe paths, unsupported compression, and
|
||||||
entries beyond the configured byte budgets remain inventory-only.
|
entries beyond the configured byte budgets remain inventory-only.
|
||||||
|
|
||||||
APK v2/v3/v4 signing blocks live outside normal ZIP entries and are not decoded
|
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.
|
unpacked ZIP-form extension package when needed.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Package Tools
|
- Project: Package Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
|
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Toolbox app id: `de.add-ideas.package-tools`
|
- Toolbox app id: `de.add-ideas.package-tools`
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
|
|||||||
artifact at build time.
|
artifact at build time.
|
||||||
|
|
||||||
- React and React DOM — MIT
|
- 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
|
- zip.js — BSD-3-Clause
|
||||||
- @xmldom/xmldom — MIT
|
- @xmldom/xmldom — MIT
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -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,
|
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
|
determines a package adapter, parses static XML or bounded JSON, and builds
|
||||||
references and diagnostics.
|
references and diagnostics.
|
||||||
3. Tree and comparison modules create deterministic projections without
|
3. Tree comparison first reports metadata-only candidates as unverified. On an
|
||||||
reading entry payloads.
|
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
|
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
|
for one bounded entry. Markup is text-only; only browser-supported raster
|
||||||
image/audio/video blobs receive local object URLs.
|
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
|
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
|
relative URLs so it works under nested portal paths, and a service worker caches
|
||||||
same-origin resources after first use.
|
same-origin resources after first use.
|
||||||
|
|||||||
@@ -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
|
no telemetry, DNS lookup, external relationship fetch, certificate lookup or
|
||||||
signature validation. Export occurs only after an explicit button press.
|
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,
|
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
|
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.
|
at 5,000 tree/comparison rows while diagnostics cover the bounded inventory.
|
||||||
|
|||||||
Generated
+20
-20
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "package-tools",
|
"name": "package-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "package-tools",
|
"name": "package-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@xmldom/xmldom": "0.9.12",
|
"@xmldom/xmldom": "0.9.12",
|
||||||
"@zip.js/zip.js": "2.9.0",
|
"@zip.js/zip.js": "2.9.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -45,24 +45,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
"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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
"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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"license": "GPL-3.0-or-later"
|
"license": "GPL-3.0-or-later"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"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.2.3/toolbox-shell-react-0.2.3.tgz",
|
"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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -70,13 +70,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"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.2.3/toolbox-testkit-0.2.3.tgz",
|
"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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "package-tools",
|
"name": "package-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and compare compound ZIP packages locally in the browser.",
|
"description": "Inspect and compare compound ZIP packages locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,16 +39,16 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@xmldom/xmldom": "0.9.12",
|
"@xmldom/xmldom": "0.9.12",
|
||||||
"@zip.js/zip.js": "2.9.0",
|
"@zip.js/zip.js": "2.9.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
|
- Add bounded ZIP/ZIP64 inventory, safe package tree and verified entry reads.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+16
-4
@@ -6,7 +6,7 @@ bounded, format-aware interpretation for EPUB, OOXML (`.docx`, `.xlsx`,
|
|||||||
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
|
`.pptx`), OpenDocument (`.odt`, `.ods`, `.odp`), JAR, APK, and Chrome/Firefox
|
||||||
extension packages.
|
extension packages.
|
||||||
|
|
||||||
Version 0.1.0 can:
|
Version 0.2.0 can:
|
||||||
|
|
||||||
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
|
- inventory paths, sizes, compression, CRC-32, ZIP64 and encryption state;
|
||||||
- reject traversal, ambiguous, colliding and special-entry paths;
|
- 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;
|
- interpret OOXML content types and internal/external relationships;
|
||||||
- inspect EPUB container/package metadata and manifest/spine consistency;
|
- inspect EPUB container/package metadata and manifest/spine consistency;
|
||||||
- inspect ODF manifests, JAR manifests and WebExtension manifests;
|
- 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;
|
- flag missing relationship/manifest targets and unreferenced embedded media;
|
||||||
- preview bounded text, raster images, audio and video, and download a strictly
|
- preview bounded text, raster images, audio and video, and download a strictly
|
||||||
re-read entry;
|
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.
|
- 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,
|
All processing happens in the browser. The app has no telemetry, remote parser,
|
||||||
CDN assets or automatic network lookups.
|
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,
|
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
|
an APK resource decoder, a Java class analyser, an extension sandbox, or a
|
||||||
signature verifier. It never establishes authenticity or publisher trust.
|
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
|
Encrypted entries, split archives, unsafe paths, unsupported compression, and
|
||||||
entries beyond the configured byte budgets remain inventory-only.
|
entries beyond the configured byte budgets remain inventory-only.
|
||||||
|
|
||||||
APK v2/v3/v4 signing blocks live outside normal ZIP entries and are not decoded
|
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.
|
unpacked ZIP-form extension package when needed.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Package Tools
|
- Project: Package Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
|
- Canonical repository: https://git.add-ideas.de/lotobo/package-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Toolbox app id: `de.add-ideas.package-tools`
|
- Toolbox app id: `de.add-ideas.package-tools`
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
|
|||||||
artifact at build time.
|
artifact at build time.
|
||||||
|
|
||||||
- React and React DOM — MIT
|
- 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
|
- zip.js — BSD-3-Clause
|
||||||
- @xmldom/xmldom — MIT
|
- @xmldom/xmldom — MIT
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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
|
determines a package adapter, parses static XML or bounded JSON, and builds
|
||||||
references and diagnostics.
|
references and diagnostics.
|
||||||
3. Tree and comparison modules create deterministic projections without
|
3. Tree comparison first reports metadata-only candidates as unverified. On an
|
||||||
reading entry payloads.
|
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
|
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
|
for one bounded entry. Markup is text-only; only browser-supported raster
|
||||||
image/audio/video blobs receive local object URLs.
|
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
|
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
|
relative URLs so it works under nested portal paths, and a service worker caches
|
||||||
same-origin resources after first use.
|
same-origin resources after first use.
|
||||||
|
|||||||
@@ -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
|
no telemetry, DNS lookup, external relationship fetch, certificate lookup or
|
||||||
signature validation. Export occurs only after an explicit button press.
|
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,
|
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
|
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.
|
at 5,000 tree/comparison rows while diagnostics cover the bounded inventory.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = "package-tools-v0.1.0";
|
const CACHE = "package-tools-v0.2.0";
|
||||||
const APP = [
|
const APP = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|||||||
+50
-3
@@ -3,12 +3,21 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.package-tools",
|
"id": "de.add-ideas.package-tools",
|
||||||
"name": "Package Tools",
|
"name": "Package Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect compound packages locally.",
|
"description": "Inspect, compare and extract compound packages locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "developer", "forensics"],
|
"categories": ["documents", "developer", "forensics"],
|
||||||
"tags": ["zip", "epub", "ooxml", "odf", "jar", "apk", "extension"],
|
"tags": [
|
||||||
|
"zip",
|
||||||
|
"epub",
|
||||||
|
"ooxml",
|
||||||
|
"odf",
|
||||||
|
"jar",
|
||||||
|
"apk",
|
||||||
|
"dependencies",
|
||||||
|
"signatures"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -21,6 +30,44 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+267
-11
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
stableStringify,
|
stableStringify,
|
||||||
triggerBlobDownload,
|
triggerBlobDownload,
|
||||||
@@ -7,8 +7,14 @@ import { formatBytes } from "../archive/limits";
|
|||||||
import type { ArchiveEntryRecord } from "../archive/types";
|
import type { ArchiveEntryRecord } from "../archive/types";
|
||||||
import { analyzePackage, entryBlob, previewEntry } from "../package/analyze";
|
import { analyzePackage, entryBlob, previewEntry } from "../package/analyze";
|
||||||
import { compareInventories } from "../package/compare";
|
import { compareInventories } from "../package/compare";
|
||||||
|
import { verifyPackageContents } from "../package/content-compare";
|
||||||
import { buildPackageTree } from "../package/tree";
|
import { buildPackageTree } from "../package/tree";
|
||||||
import type { EntryPreview, PackageDocument, TreeNode } from "../package/types";
|
import type {
|
||||||
|
ContentComparisonEvidenceMap,
|
||||||
|
EntryPreview,
|
||||||
|
PackageDocument,
|
||||||
|
TreeNode,
|
||||||
|
} from "../package/types";
|
||||||
|
|
||||||
type Tab =
|
type Tab =
|
||||||
| "overview"
|
| "overview"
|
||||||
@@ -17,6 +23,7 @@ type Tab =
|
|||||||
| "relationships"
|
| "relationships"
|
||||||
| "diagnostics"
|
| "diagnostics"
|
||||||
| "signatures"
|
| "signatures"
|
||||||
|
| "supply-chain"
|
||||||
| "compare";
|
| "compare";
|
||||||
|
|
||||||
export function Workbench() {
|
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() {
|
function exportReport() {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const report = {
|
const report = {
|
||||||
application: "Package Tools 0.1.0",
|
application: "Package Tools 0.2.0",
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
package: {
|
package: {
|
||||||
name: active.name,
|
name: active.name,
|
||||||
@@ -110,6 +146,8 @@ export function Workbench() {
|
|||||||
diagnostics: active.diagnostics,
|
diagnostics: active.diagnostics,
|
||||||
relationships: active.relationships,
|
relationships: active.relationships,
|
||||||
signatures: active.signatures,
|
signatures: active.signatures,
|
||||||
|
dependencies: active.dependencies,
|
||||||
|
licenses: active.licenses,
|
||||||
boundary:
|
boundary:
|
||||||
"Signature files are inventoried without cryptographic validation or trust claims.",
|
"Signature files are inventoried without cryptographic validation or trust claims.",
|
||||||
};
|
};
|
||||||
@@ -195,6 +233,7 @@ export function Workbench() {
|
|||||||
["relationships", "Relationships"],
|
["relationships", "Relationships"],
|
||||||
["diagnostics", "Diagnostics"],
|
["diagnostics", "Diagnostics"],
|
||||||
["signatures", "Signatures"],
|
["signatures", "Signatures"],
|
||||||
|
["supply-chain", "Dependencies & licences"],
|
||||||
...(documents.length > 1 ? [["compare", "Compare"]] : []),
|
...(documents.length > 1 ? [["compare", "Compare"]] : []),
|
||||||
] as [Tab, string][]
|
] as [Tab, string][]
|
||||||
).map(([id, label]) => (
|
).map(([id, label]) => (
|
||||||
@@ -253,6 +292,7 @@ export function Workbench() {
|
|||||||
preview={preview}
|
preview={preview}
|
||||||
status={previewStatus}
|
status={previewStatus}
|
||||||
onDownload={downloadEntry}
|
onDownload={downloadEntry}
|
||||||
|
onShare={shareEntry}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -266,8 +306,15 @@ export function Workbench() {
|
|||||||
{active && tab === "signatures" && (
|
{active && tab === "signatures" && (
|
||||||
<SignaturesView document={active} />
|
<SignaturesView document={active} />
|
||||||
)}
|
)}
|
||||||
|
{active && tab === "supply-chain" && (
|
||||||
|
<SupplyChainView document={active} />
|
||||||
|
)}
|
||||||
{tab === "compare" && documents.length > 1 && (
|
{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,
|
preview,
|
||||||
status,
|
status,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onShare,
|
||||||
}: {
|
}: {
|
||||||
document: PackageDocument;
|
document: PackageDocument;
|
||||||
entry?: ArchiveEntryRecord;
|
entry?: ArchiveEntryRecord;
|
||||||
preview?: EntryPreview;
|
preview?: EntryPreview;
|
||||||
status: string;
|
status: string;
|
||||||
onDownload: (entry: ArchiveEntryRecord) => void;
|
onDownload: (entry: ArchiveEntryRecord) => void;
|
||||||
|
onShare: (entry: ArchiveEntryRecord) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside className="panel inspector" aria-label="Entry inspector">
|
<aside className="panel inspector" aria-label="Entry inspector">
|
||||||
@@ -473,6 +522,18 @@ function EntryInspector({
|
|||||||
>
|
>
|
||||||
Download verified entry
|
Download verified entry
|
||||||
</button>
|
</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 && (
|
{status && (
|
||||||
<p className="boundary" role="status">
|
<p className="boundary" role="status">
|
||||||
{status}
|
{status}
|
||||||
@@ -667,6 +728,19 @@ function SignaturesView({ document }: { document: PackageDocument }) {
|
|||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<strong>{item.format}</strong>
|
<strong>{item.format}</strong>
|
||||||
<code>{item.path}</code>
|
<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>
|
<p>{item.note}</p>
|
||||||
</li>
|
</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({
|
function CompareView({
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
@@ -685,20 +832,97 @@ function CompareView({
|
|||||||
left: PackageDocument;
|
left: PackageDocument;
|
||||||
right: PackageDocument;
|
right: PackageDocument;
|
||||||
}) {
|
}) {
|
||||||
const comparison = compareInventories(left, right);
|
const [evidence, setEvidence] = useState<ContentComparisonEvidenceMap>({});
|
||||||
const changed = comparison.filter((item) => item.status !== "same");
|
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 (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Path, size and CRC inventory</p>
|
<p className="eyebrow">Decompressed content evidence</p>
|
||||||
<h2>Compare packages</h2>
|
<h2>Compare packages</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="count">{changed.length} differences</span>
|
<span className="count">{attention.length} require attention</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
{left.name} ↔ {right.name}
|
{left.name} ↔ {right.name}
|
||||||
</p>
|
</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">
|
<div className="table-scroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -707,10 +931,11 @@ function CompareView({
|
|||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
<th>{left.name}</th>
|
<th>{left.name}</th>
|
||||||
<th>{right.name}</th>
|
<th>{right.name}</th>
|
||||||
|
<th>Evidence</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{changed.slice(0, 5000).map((item) => (
|
{attention.slice(0, 5000).map((item) => (
|
||||||
<tr key={item.path}>
|
<tr key={item.path}>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
@@ -722,13 +947,44 @@ function CompareView({
|
|||||||
<td className="wrap">{item.path}</td>
|
<td className="wrap">{item.path}</td>
|
||||||
<td>{item.left ? formatBytes(item.left.size) : "—"}</td>
|
<td>{item.left ? formatBytes(item.left.size) : "—"}</td>
|
||||||
<td>{item.right ? formatBytes(item.right.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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{changed.length === 0 && (
|
{attention.length === 0 && (
|
||||||
<p className="success">The bounded inventories match.</p>
|
<p className="success">
|
||||||
|
Every comparable file has matching decompressed SHA-256 evidence.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
+189
-5
@@ -12,9 +12,11 @@ import type {
|
|||||||
EntryAnnotation,
|
EntryAnnotation,
|
||||||
EntryPreview,
|
EntryPreview,
|
||||||
PackageDiagnostic,
|
PackageDiagnostic,
|
||||||
|
PackageDependency,
|
||||||
PackageDocument,
|
PackageDocument,
|
||||||
PackageKind,
|
PackageKind,
|
||||||
PackageMetadata,
|
PackageMetadata,
|
||||||
|
PackageLicense,
|
||||||
PackageRelationship,
|
PackageRelationship,
|
||||||
PackageSignature,
|
PackageSignature,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -111,8 +113,10 @@ export async function analyzePackage(
|
|||||||
else if (kind === "chrome-extension" || kind === "firefox-extension")
|
else if (kind === "chrome-extension" || kind === "firefox-extension")
|
||||||
analyzeExtension(state, manifestValue, kind);
|
analyzeExtension(state, manifestValue, kind);
|
||||||
|
|
||||||
|
const { dependencies, licenses } = analyzeSupplyChain(state);
|
||||||
|
|
||||||
applyReferences(state);
|
applyReferences(state);
|
||||||
const signatures = inventorySignatures(parsed.entries, kind);
|
const signatures = inventorySignatures(parsed.entries, kind, texts);
|
||||||
for (const signature of signatures)
|
for (const signature of signatures)
|
||||||
state.annotations[entryByPath.get(signature.path)?.id ?? ""] = {
|
state.annotations[entryByPath.get(signature.path)?.id ?? ""] = {
|
||||||
...(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,
|
relationships: state.relationships,
|
||||||
metadata: state.metadata,
|
metadata: state.metadata,
|
||||||
signatures,
|
signatures,
|
||||||
|
dependencies,
|
||||||
|
licenses,
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
sourceBytes: file.size,
|
sourceBytes: file.size,
|
||||||
expandedBytes: parsed.expandedBytes,
|
expandedBytes: parsed.expandedBytes,
|
||||||
@@ -269,7 +275,13 @@ function isMetadataPath(path: string): boolean {
|
|||||||
lower.endsWith(".xml") ||
|
lower.endsWith(".xml") ||
|
||||||
lower.endsWith(".json") ||
|
lower.endsWith(".json") ||
|
||||||
lower.endsWith("manifest.mf") ||
|
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(
|
diagnostic(
|
||||||
"info",
|
"info",
|
||||||
"APK_BINARY_MANIFEST",
|
"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(
|
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(
|
function inventorySignatures(
|
||||||
entries: ArchiveEntryRecord[],
|
entries: ArchiveEntryRecord[],
|
||||||
kind: PackageKind,
|
kind: PackageKind,
|
||||||
|
texts: Map<string, string>,
|
||||||
): PackageSignature[] {
|
): PackageSignature[] {
|
||||||
const output: PackageSignature[] = [];
|
const output: PackageSignature[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -855,13 +1007,45 @@ function inventorySignatures(
|
|||||||
: "JAR signature material";
|
: "JAR signature material";
|
||||||
else if (/^META-INF\/(?:mozilla|cose)\.(?:rsa|sf|manifest)$/iu.test(path))
|
else if (/^META-INF\/(?:mozilla|cose)\.(?:rsa|sf|manifest)$/iu.test(path))
|
||||||
format = "Extension signature material";
|
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({
|
output.push({
|
||||||
path,
|
path,
|
||||||
format,
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -1,8 +1,13 @@
|
|||||||
import type { PackageDocument, InventoryComparison } from "./types";
|
import type {
|
||||||
|
ContentComparisonEvidenceMap,
|
||||||
|
InventoryComparison,
|
||||||
|
PackageDocument,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export function compareInventories(
|
export function compareInventories(
|
||||||
left: PackageDocument,
|
left: PackageDocument,
|
||||||
right: PackageDocument,
|
right: PackageDocument,
|
||||||
|
evidence: ContentComparisonEvidenceMap = {},
|
||||||
): InventoryComparison[] {
|
): InventoryComparison[] {
|
||||||
const leftMap = new Map(left.entries.map((entry) => [entry.path, entry]));
|
const leftMap = new Map(left.entries.map((entry) => [entry.path, entry]));
|
||||||
const rightMap = new Map(right.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) => {
|
.map((path) => {
|
||||||
const leftEntry = leftMap.get(path);
|
const leftEntry = leftMap.get(path);
|
||||||
const rightEntry = rightMap.get(path);
|
const rightEntry = rightMap.get(path);
|
||||||
|
const content = evidence[path];
|
||||||
let status: InventoryComparison["status"];
|
let status: InventoryComparison["status"];
|
||||||
if (!leftEntry) status = "only-right";
|
if (!leftEntry) status = "only-right";
|
||||||
else if (!rightEntry) status = "only-left";
|
else if (!rightEntry) status = "only-left";
|
||||||
else if (leftEntry.kind !== rightEntry.kind) status = "kind-changed";
|
else if (leftEntry.kind !== rightEntry.kind) status = "kind-changed";
|
||||||
else if (
|
else if (leftEntry.kind === "directory") status = "same";
|
||||||
leftEntry.size === rightEntry.size &&
|
else if (leftEntry.kind !== "file") status = "unverified";
|
||||||
leftEntry.crc32 === rightEntry.crc32 &&
|
else if (content?.leftSha256 && content.rightSha256)
|
||||||
leftEntry.compressedSize === rightEntry.compressedSize
|
status =
|
||||||
)
|
content.leftSha256 === content.rightSha256 ? "same" : "changed";
|
||||||
status = "same";
|
else if (leftEntry.size !== rightEntry.size) status = "changed";
|
||||||
else status = "changed";
|
else status = "unverified";
|
||||||
return { path, status, left: leftEntry, right: rightEntry };
|
return {
|
||||||
|
path,
|
||||||
|
status,
|
||||||
|
left: leftEntry,
|
||||||
|
right: rightEntry,
|
||||||
|
...(content ? { evidence: content } : {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -43,6 +43,22 @@ export interface PackageSignature {
|
|||||||
path: string;
|
path: string;
|
||||||
format: string;
|
format: string;
|
||||||
note: 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 {
|
export interface EntryAnnotation {
|
||||||
@@ -62,6 +78,8 @@ export interface PackageDocument {
|
|||||||
relationships: PackageRelationship[];
|
relationships: PackageRelationship[];
|
||||||
metadata: PackageMetadata[];
|
metadata: PackageMetadata[];
|
||||||
signatures: PackageSignature[];
|
signatures: PackageSignature[];
|
||||||
|
dependencies: PackageDependency[];
|
||||||
|
licenses: PackageLicense[];
|
||||||
annotations: Record<string, EntryAnnotation>;
|
annotations: Record<string, EntryAnnotation>;
|
||||||
sourceBytes: number;
|
sourceBytes: number;
|
||||||
expandedBytes: number;
|
expandedBytes: number;
|
||||||
@@ -71,11 +89,37 @@ export interface PackageDocument {
|
|||||||
|
|
||||||
export interface InventoryComparison {
|
export interface InventoryComparison {
|
||||||
path: string;
|
path: string;
|
||||||
status: "same" | "changed" | "only-left" | "only-right" | "kind-changed";
|
status:
|
||||||
|
| "same"
|
||||||
|
| "changed"
|
||||||
|
| "unverified"
|
||||||
|
| "only-left"
|
||||||
|
| "only-right"
|
||||||
|
| "kind-changed";
|
||||||
left?: ArchiveEntryRecord;
|
left?: ArchiveEntryRecord;
|
||||||
right?: 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 {
|
export interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ td small {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.supply-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.8fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.summary-card {
|
.summary-card {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,9 @@ th {
|
|||||||
.split-view {
|
.split-view {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.supply-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.inspector {
|
.inspector {
|
||||||
position: static;
|
position: static;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
|||||||
@@ -3,12 +3,21 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.package-tools",
|
"id": "de.add-ideas.package-tools",
|
||||||
"name": "Package Tools",
|
"name": "Package Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect compound packages locally.",
|
"description": "Inspect, compare and extract compound packages locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "developer", "forensics"],
|
"categories": ["documents", "developer", "forensics"],
|
||||||
"tags": ["zip", "epub", "ooxml", "odf", "jar", "apk", "extension"],
|
"tags": [
|
||||||
|
"zip",
|
||||||
|
"epub",
|
||||||
|
"ooxml",
|
||||||
|
"odf",
|
||||||
|
"jar",
|
||||||
|
"apk",
|
||||||
|
"dependencies",
|
||||||
|
"signatures"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -21,6 +30,47 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ test("compares two local package inventories", async ({ page }) => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("row", { name: /changed word\/document\.xml/u }),
|
page.getByRole("row", { name: /changed word\/document\.xml/u }),
|
||||||
).toBeVisible();
|
).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 ({
|
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");
|
const manifest = await request.get("/deep/nested/package/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.package-tools",
|
id: "de.add-ideas.package-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
privacy: { processing: "local", telemetry: false },
|
privacy: { processing: "local", telemetry: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -104,7 +104,19 @@ describe("compound package analysis", () => {
|
|||||||
const jar = await analyzePackage(
|
const jar = await analyzePackage(
|
||||||
archive("tool.jar", {
|
archive("tool.jar", {
|
||||||
"META-INF/MANIFEST.MF": strToU8(
|
"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]),
|
"example/Main.class": new Uint8Array([0xca, 0xfe, 0xba, 0xbe]),
|
||||||
}),
|
}),
|
||||||
@@ -115,6 +127,30 @@ describe("compound package analysis", () => {
|
|||||||
name: "Main-Class",
|
name: "Main-Class",
|
||||||
value: "example.Main",
|
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 () => {
|
it("adapts APK and both WebExtension identities with bounded inventories", async () => {
|
||||||
|
|||||||
@@ -45,7 +45,23 @@ describe("inventory comparison", () => {
|
|||||||
changed: "changed",
|
changed: "changed",
|
||||||
left: "only-left",
|
left: "only-left",
|
||||||
right: "only-right",
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user