@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { zipSync } from 'fflate';
|
|
2
|
+
import { extractBoundedZipEntries } from "./bounded-zip-extraction.mjs";
|
|
3
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES, } from "../contracts/investigation-package-limits.mjs";
|
|
4
|
+
// Payload admission follows selected evidence files. Separate tag and path
|
|
5
|
+
// bounds limit metadata parsing and directory work, not the BagIt specification.
|
|
6
|
+
export const MAX_BAGIT_PAYLOAD_FILES = MAX_INVESTIGATION_MANIFEST_ARTIFACTS;
|
|
7
|
+
export const MAX_BAGIT_PAYLOAD_BYTES = MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES;
|
|
8
|
+
export const MAX_BAGIT_FILE_BYTES = MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES;
|
|
9
|
+
export const MAX_BAGIT_TAG_FILES = 32;
|
|
10
|
+
export const MAX_BAGIT_TAG_BYTES = MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES;
|
|
11
|
+
export const MAX_BAGIT_TOTAL_TAG_BYTES = 4 * MAX_BAGIT_TAG_BYTES;
|
|
12
|
+
export const MAX_BAGIT_PATH_BYTES = 1024;
|
|
13
|
+
export const MAX_BAGIT_DEPTH = 16;
|
|
14
|
+
export const MAX_BAGIT_FILES = MAX_BAGIT_PAYLOAD_FILES + MAX_BAGIT_TAG_FILES;
|
|
15
|
+
export const MAX_BAGIT_ENTRIES = MAX_BAGIT_FILES * (MAX_BAGIT_DEPTH + 1);
|
|
16
|
+
export const MAX_BAGIT_ZIP_BYTES = MAX_BAGIT_PAYLOAD_BYTES + MAX_BAGIT_TOTAL_TAG_BYTES
|
|
17
|
+
+ MAX_BAGIT_ENTRIES * (76 + 2 * MAX_BAGIT_PATH_BYTES) + 65_557;
|
|
18
|
+
export class UnsupportedBagItError extends TypeError {
|
|
19
|
+
}
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
22
|
+
const order = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
23
|
+
const payloadManifest = /^manifest-([a-z0-9]{1,32})\.txt$/u;
|
|
24
|
+
const tagManifest = /^tagmanifest-([a-z0-9]{1,32})\.txt$/u;
|
|
25
|
+
/** A deliberately portable path profile; no path is silently rewritten. */
|
|
26
|
+
export function bagItPath(value) {
|
|
27
|
+
if (typeof value !== 'string' || !value || value.length > MAX_BAGIT_PATH_BYTES
|
|
28
|
+
|| encoder.encode(value).byteLength > MAX_BAGIT_PATH_BYTES
|
|
29
|
+
|| decoder.decode(encoder.encode(value)) !== value || /[\x00-\x1f\x7f\\:<>"|?*]/u.test(value)) {
|
|
30
|
+
throw new TypeError('BagIt contains an unsupported or unsafe path.');
|
|
31
|
+
}
|
|
32
|
+
const parts = (value.endsWith('/') ? value.slice(0, -1) : value).split('/');
|
|
33
|
+
if (parts[0]?.startsWith(' ') || parts.length > MAX_BAGIT_DEPTH || parts.some(part => !part || part === '.' || part === '..'
|
|
34
|
+
|| /[. ]$/u.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))) {
|
|
35
|
+
throw new TypeError('BagIt contains an unsupported or unsafe path.');
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
export function bagItEntryMaximum(path) {
|
|
40
|
+
bagItPath(path);
|
|
41
|
+
return path.endsWith('/') ? 0 : path.startsWith('data/') ? MAX_BAGIT_FILE_BYTES : MAX_BAGIT_TAG_BYTES;
|
|
42
|
+
}
|
|
43
|
+
/** Validate metadata before file reads or allocation; directories end in '/'. */
|
|
44
|
+
function admission() {
|
|
45
|
+
const names = new Map(), directories = new Set(), spelling = new Map();
|
|
46
|
+
let files = 0, tags = 0, payloadBytes = 0, tagBytes = 0, count = 0;
|
|
47
|
+
return (entry) => {
|
|
48
|
+
if (++count > MAX_BAGIT_ENTRIES)
|
|
49
|
+
throw new TypeError('BagIt exceeds its entry limit.');
|
|
50
|
+
const path = bagItPath(entry.path), directory = path.endsWith('/');
|
|
51
|
+
const raw = directory ? path.slice(0, -1) : path, key = raw.normalize('NFC').toLowerCase();
|
|
52
|
+
if (names.has(key) || (!directory && directories.has(key)))
|
|
53
|
+
throw new TypeError('BagIt contains colliding paths.');
|
|
54
|
+
names.set(key, directory);
|
|
55
|
+
const parts = raw.split('/');
|
|
56
|
+
while (parts.length) {
|
|
57
|
+
const parent = parts.join('/'), normal = parent.normalize('NFC').toLowerCase();
|
|
58
|
+
if (spelling.has(normal) && spelling.get(normal) !== parent)
|
|
59
|
+
throw new TypeError('BagIt contains colliding path spellings.');
|
|
60
|
+
spelling.set(normal, parent);
|
|
61
|
+
if (parent !== raw || directory) {
|
|
62
|
+
if (names.get(normal) === false)
|
|
63
|
+
throw new TypeError('BagIt contains a file-directory collision.');
|
|
64
|
+
directories.add(normal);
|
|
65
|
+
}
|
|
66
|
+
parts.pop();
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength < 0 || entry.byteLength > bagItEntryMaximum(path))
|
|
69
|
+
throw new TypeError('BagIt entry exceeds its byte limit.');
|
|
70
|
+
if (directory)
|
|
71
|
+
return;
|
|
72
|
+
if (path === 'data')
|
|
73
|
+
throw new TypeError('BagIt data must be a directory.');
|
|
74
|
+
if (path.startsWith('data/')) {
|
|
75
|
+
files++;
|
|
76
|
+
payloadBytes += entry.byteLength;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
tags++;
|
|
80
|
+
tagBytes += entry.byteLength;
|
|
81
|
+
}
|
|
82
|
+
if (files > MAX_BAGIT_PAYLOAD_FILES || tags > MAX_BAGIT_TAG_FILES
|
|
83
|
+
|| payloadBytes > MAX_BAGIT_PAYLOAD_BYTES || tagBytes > MAX_BAGIT_TOTAL_TAG_BYTES)
|
|
84
|
+
throw new TypeError('BagIt exceeds its file or combined byte limit.');
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function assertBagItSelection(entries) {
|
|
88
|
+
if (!Array.isArray(entries) || entries.length > MAX_BAGIT_ENTRIES)
|
|
89
|
+
throw new TypeError('BagIt exceeds its entry limit.');
|
|
90
|
+
const admit = admission();
|
|
91
|
+
for (const entry of entries)
|
|
92
|
+
admit(entry);
|
|
93
|
+
}
|
|
94
|
+
function capture(input) {
|
|
95
|
+
if (!(input instanceof Map) || input.size > MAX_BAGIT_ENTRIES)
|
|
96
|
+
throw new TypeError('BagIt requires bounded selected file entries.');
|
|
97
|
+
const entries = [...input].map(([path, bytes]) => {
|
|
98
|
+
if (!(bytes instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer))
|
|
99
|
+
throw new TypeError('BagIt requires non-shared file bytes.');
|
|
100
|
+
return { path, byteLength: bytes.byteLength };
|
|
101
|
+
});
|
|
102
|
+
assertBagItSelection(entries);
|
|
103
|
+
return new Map([...input].map(([path, bytes]) => [path, new Uint8Array(bytes)]));
|
|
104
|
+
}
|
|
105
|
+
function lines(bytes) {
|
|
106
|
+
const text = decoder.decode(bytes);
|
|
107
|
+
if (text.startsWith('\uFEFF') || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(text))
|
|
108
|
+
throw new TypeError('BagIt text tags must be UTF-8 without a byte-order mark or control characters.');
|
|
109
|
+
const result = text.split(/\r\n|\r|\n/u);
|
|
110
|
+
if (result.at(-1) === '')
|
|
111
|
+
result.pop();
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
function manifestPath(value) {
|
|
115
|
+
// RFC 8493 escapes only percent, CR and LF, not URL path separators.
|
|
116
|
+
if (/%(?!25|0[dDaA])/u.test(value))
|
|
117
|
+
throw new TypeError('BagIt manifest contains an unsupported path escape.');
|
|
118
|
+
return bagItPath(value.replace(/%(25|0d|0a)/giu, (_, code) => String.fromCharCode(Number.parseInt(code, 16))));
|
|
119
|
+
}
|
|
120
|
+
function readManifest(name, bytes, tag, resolvePath) {
|
|
121
|
+
const code = (tag ? tagManifest : payloadManifest).exec(name)?.[1];
|
|
122
|
+
const algorithm = code === 'sha256' || code === 'sha512' ? code : null;
|
|
123
|
+
const rows = new Map();
|
|
124
|
+
for (const line of lines(bytes)) {
|
|
125
|
+
const match = /^([a-fA-F0-9]{1,512})[ \t]+(.+)$/u.exec(line);
|
|
126
|
+
if (!match || (algorithm && match[1].length !== (algorithm === 'sha256' ? 64 : 128)))
|
|
127
|
+
throw new TypeError('BagIt manifest has an invalid checksum line.');
|
|
128
|
+
const path = resolvePath(manifestPath(match[2]));
|
|
129
|
+
if (path.endsWith('/') || (tag ? path.startsWith('data/') || tagManifest.test(path) : !path.startsWith('data/')) || rows.has(path))
|
|
130
|
+
throw new TypeError('BagIt manifest has a duplicate or misplaced entry.');
|
|
131
|
+
if (rows.size >= (tag ? MAX_BAGIT_TAG_FILES : MAX_BAGIT_PAYLOAD_FILES))
|
|
132
|
+
throw new TypeError('BagIt manifest exceeds its entry limit.');
|
|
133
|
+
rows.set(path, match[1].toLowerCase());
|
|
134
|
+
}
|
|
135
|
+
return { name, algorithm, rows };
|
|
136
|
+
}
|
|
137
|
+
async function checksum(bytes, algorithm) {
|
|
138
|
+
if (!(bytes.buffer instanceof ArrayBuffer))
|
|
139
|
+
throw new TypeError('BagIt requires non-shared bytes.');
|
|
140
|
+
const digest = await crypto.subtle.digest(algorithm === 'sha256' ? 'SHA-256' : 'SHA-512', new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
141
|
+
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
142
|
+
}
|
|
143
|
+
function readFetch(bytes, resolvePath) {
|
|
144
|
+
const entries = new Map();
|
|
145
|
+
if (!bytes)
|
|
146
|
+
return entries;
|
|
147
|
+
for (const line of lines(bytes)) {
|
|
148
|
+
const match = /^([a-z][a-z0-9+.-]*:[^\s]{1,8192})[ \t]+(-|[0-9]{1,16})[ \t]+(.+)$/iu.exec(line);
|
|
149
|
+
if (!match)
|
|
150
|
+
throw new TypeError('BagIt fetch declaration is malformed.');
|
|
151
|
+
const uri = match[1];
|
|
152
|
+
if (/[^a-zA-Z0-9._~:/?\[\]@!$&'()*+,;=%-]/u.test(uri) || /%(?![a-fA-F0-9]{2})/u.test(uri))
|
|
153
|
+
throw new TypeError('BagIt fetch declaration requires an absolute URI without a fragment.');
|
|
154
|
+
try {
|
|
155
|
+
new URL(uri);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new TypeError('BagIt fetch declaration requires a valid absolute URI.');
|
|
159
|
+
}
|
|
160
|
+
const path = resolvePath(manifestPath(match[3]));
|
|
161
|
+
const size = match[2] === '-' ? null : Number(match[2]);
|
|
162
|
+
if (!path.startsWith('data/') || path.endsWith('/') || entries.has(path) || entries.size >= MAX_BAGIT_PAYLOAD_FILES
|
|
163
|
+
|| (size !== null && !Number.isSafeInteger(size)))
|
|
164
|
+
throw new TypeError('BagIt fetch declaration is invalid.');
|
|
165
|
+
// The URI is deliberately neither retained nor dereferenced.
|
|
166
|
+
entries.set(path, size);
|
|
167
|
+
}
|
|
168
|
+
return entries;
|
|
169
|
+
}
|
|
170
|
+
function payloadOxum(bytes) {
|
|
171
|
+
if (!bytes)
|
|
172
|
+
return null;
|
|
173
|
+
let current = null;
|
|
174
|
+
let oxum = null;
|
|
175
|
+
const finish = () => {
|
|
176
|
+
if (current?.name !== 'payload-oxum')
|
|
177
|
+
return;
|
|
178
|
+
if (oxum !== null || !/^[0-9]{1,16}\.[0-9]{1,16}$/u.test(current.value))
|
|
179
|
+
throw new TypeError('BagIt payload size declaration is invalid or repeated.');
|
|
180
|
+
oxum = current.value;
|
|
181
|
+
};
|
|
182
|
+
for (const line of lines(bytes)) {
|
|
183
|
+
if (/^[ \t]/u.test(line)) {
|
|
184
|
+
if (!current)
|
|
185
|
+
throw new TypeError('BagIt metadata continuation has no field.');
|
|
186
|
+
current.value += ` ${line.trim()}`;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
finish();
|
|
190
|
+
const match = /^([^:]+):[ \t](.*)$/u.exec(line);
|
|
191
|
+
if (!match || match[1] !== match[1].trim())
|
|
192
|
+
throw new TypeError('BagIt metadata field is malformed.');
|
|
193
|
+
current = { name: match[1].toLowerCase(), value: match[2] };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
finish();
|
|
197
|
+
return oxum;
|
|
198
|
+
}
|
|
199
|
+
/** Checks every declared checksum. Payload content is never interpreted. */
|
|
200
|
+
export async function inspectBagItEntries(input) {
|
|
201
|
+
const files = capture(input), declaration = files.get('bagit.txt');
|
|
202
|
+
if (!declaration)
|
|
203
|
+
throw new TypeError('BagIt declaration is missing.');
|
|
204
|
+
const declared = lines(declaration);
|
|
205
|
+
if (declared.length !== 2 || !/^BagIt-Version: [0-9]+\.[0-9]+$/u.test(declared[0]) || !/^Tag-File-Character-Encoding: .+$/u.test(declared[1]))
|
|
206
|
+
throw new TypeError('BagIt declaration is malformed.');
|
|
207
|
+
if (declared[0] !== 'BagIt-Version: 1.0' || declared[1].toLowerCase() !== 'tag-file-character-encoding: utf-8')
|
|
208
|
+
throw new UnsupportedBagItError('Only BagIt 1.0 with UTF-8 text tags is supported.');
|
|
209
|
+
if (!files.has('data/') && ![...files.keys()].some(path => path.startsWith('data/')))
|
|
210
|
+
throw new TypeError('BagIt payload directory is missing.');
|
|
211
|
+
// Admission has already refused ambiguous normalised names. This unique NFC
|
|
212
|
+
// fallback accommodates filesystems that change Unicode normalisation.
|
|
213
|
+
const normalisedPaths = new Map([...files.keys()].map(path => [path.normalize('NFC'), path]));
|
|
214
|
+
const resolvePath = (path) => files.has(path) ? path : normalisedPaths.get(path.normalize('NFC')) ?? path.normalize('NFC');
|
|
215
|
+
const manifests = [...files].filter(([name]) => payloadManifest.test(name)).map(([name, bytes]) => readManifest(name, bytes, false, resolvePath));
|
|
216
|
+
const tagManifests = [...files].filter(([name]) => tagManifest.test(name)).map(([name, bytes]) => readManifest(name, bytes, true, resolvePath));
|
|
217
|
+
if (!manifests.length)
|
|
218
|
+
throw new TypeError('BagIt requires a payload manifest.');
|
|
219
|
+
const payload = [...files.keys()].filter(path => path.startsWith('data/') && !path.endsWith('/'));
|
|
220
|
+
const required = new Set(manifests.flatMap(manifest => [...manifest.rows.keys()]));
|
|
221
|
+
if (required.size > MAX_BAGIT_PAYLOAD_FILES)
|
|
222
|
+
throw new TypeError('BagIt exceeds its declared payload limit.');
|
|
223
|
+
const issues = new Set();
|
|
224
|
+
if (manifests.some(manifest => required.size !== manifest.rows.size || [...required].some(path => !manifest.rows.has(path))))
|
|
225
|
+
issues.add('Payload manifests disagree about file membership.');
|
|
226
|
+
if (payload.some(path => !required.has(path)))
|
|
227
|
+
issues.add('A payload file is absent from the payload manifests.');
|
|
228
|
+
if (tagManifests.some(manifest => manifests.some(payload => !manifest.rows.has(payload.name))))
|
|
229
|
+
issues.add('A tag manifest does not list every payload manifest.');
|
|
230
|
+
const fetch = readFetch(files.get('fetch.txt'), resolvePath);
|
|
231
|
+
if ([...fetch.keys()].some(path => manifests.some(manifest => !manifest.rows.has(path))))
|
|
232
|
+
issues.add('A fetch entry is absent from a payload manifest.');
|
|
233
|
+
if ([...fetch].some(([path, size]) => size !== null && files.has(path) && files.get(path).byteLength !== size))
|
|
234
|
+
issues.add('A present payload does not match its fetch byte declaration.');
|
|
235
|
+
const missingTags = tagManifests.some(manifest => [...manifest.rows.keys()].some(path => !files.has(path)));
|
|
236
|
+
const filesPresent = !missingTags && [...required].every(path => files.has(path));
|
|
237
|
+
const payloadBytes = payload.reduce((sum, path) => sum + files.get(path).byteLength, 0);
|
|
238
|
+
const oxum = payloadOxum(files.get('bag-info.txt'));
|
|
239
|
+
if (filesPresent && oxum !== null && oxum.split('.').map(Number).join('.') !== `${payloadBytes}.${payload.length}`)
|
|
240
|
+
issues.add('Payload-Oxum does not match the selected payload.');
|
|
241
|
+
const complete = filesPresent && issues.size === 0;
|
|
242
|
+
const digests = new Map();
|
|
243
|
+
const tagStates = new Map();
|
|
244
|
+
const states = new Map([...new Set([...required, ...payload])].sort(order).map(path => [path, files.has(path) ? 'verified' : 'missing']));
|
|
245
|
+
for (const manifest of [...manifests, ...tagManifests]) {
|
|
246
|
+
for (const [path, expected] of manifest.rows) {
|
|
247
|
+
const bytes = files.get(path);
|
|
248
|
+
if (!bytes)
|
|
249
|
+
continue;
|
|
250
|
+
const targetStates = path.startsWith('data/') ? states : tagStates;
|
|
251
|
+
if (!targetStates.has(path))
|
|
252
|
+
targetStates.set(path, 'verified');
|
|
253
|
+
if (!manifest.algorithm) {
|
|
254
|
+
if (targetStates.get(path) === 'verified')
|
|
255
|
+
targetStates.set(path, 'unverified');
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const key = `${manifest.algorithm}:${path}`;
|
|
259
|
+
const actual = digests.get(key) ?? await checksum(bytes, manifest.algorithm);
|
|
260
|
+
digests.set(key, actual);
|
|
261
|
+
if (actual !== expected) {
|
|
262
|
+
issues.add('A declared checksum does not match its selected file.');
|
|
263
|
+
targetStates.set(path, 'mismatch');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const path of payload)
|
|
268
|
+
if (!required.has(path))
|
|
269
|
+
states.set(path, 'unverified');
|
|
270
|
+
const unsupportedManifests = [...manifests, ...tagManifests].filter(manifest => !manifest.algorithm).length;
|
|
271
|
+
const state = issues.size ? 'invalid' : !complete ? 'incomplete' : unsupportedManifests ? 'unsupported' : 'valid';
|
|
272
|
+
const contents = new Map();
|
|
273
|
+
const entries = [...states].map(([path, entryState], index) => {
|
|
274
|
+
const id = `artifact-${index + 1}`, bytes = files.get(path);
|
|
275
|
+
if (bytes && entryState === 'verified' && state === 'valid')
|
|
276
|
+
contents.set(id, bytes);
|
|
277
|
+
return { id, byteLength: bytes?.byteLength ?? null, state: entryState };
|
|
278
|
+
});
|
|
279
|
+
return { review: {
|
|
280
|
+
version: '1.0', state, complete, checksumsVerified: state === 'valid',
|
|
281
|
+
algorithms: [...new Set([...manifests, ...tagManifests].flatMap(manifest => manifest.algorithm ? [manifest.algorithm] : []))].sort(order),
|
|
282
|
+
unsupportedManifests, payloadBytes, tagFiles: [...files.keys()].filter(path => !path.startsWith('data/') && !path.endsWith('/')).length,
|
|
283
|
+
verifiedTagFiles: [...tagStates.values()].filter(state => state === 'verified').length, fetchEntries: fetch.size, fetchMissing: [...fetch.keys()].filter(path => !files.has(path)).length,
|
|
284
|
+
entries, issues: [...issues],
|
|
285
|
+
}, contents, inputBytes: [...files.values()].reduce((sum, bytes) => sum + bytes.byteLength, 0) };
|
|
286
|
+
}
|
|
287
|
+
export function encodeBagItEntries(input) {
|
|
288
|
+
const files = capture(input);
|
|
289
|
+
const bytes = zipSync(Object.fromEntries([...files].sort(([left], [right]) => order(left, right))), { level: 0, mtime: new Date(1980, 0, 1) });
|
|
290
|
+
if (bytes.byteLength > MAX_BAGIT_ZIP_BYTES)
|
|
291
|
+
throw new TypeError('BagIt ZIP exceeds its byte limit.');
|
|
292
|
+
return bytes;
|
|
293
|
+
}
|
|
294
|
+
export function readBagItZip(input) {
|
|
295
|
+
if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer) || input.byteLength < 22 || input.byteLength > MAX_BAGIT_ZIP_BYTES)
|
|
296
|
+
throw new TypeError('BagIt ZIP requires bounded non-shared input bytes.');
|
|
297
|
+
const admit = admission();
|
|
298
|
+
return extractBoundedZipEntries(input, {
|
|
299
|
+
keyForName: bagItPath, maximumEntries: MAX_BAGIT_ENTRIES,
|
|
300
|
+
maximumSelectedBytes: MAX_BAGIT_PAYLOAD_BYTES + MAX_BAGIT_TOTAL_TAG_BYTES,
|
|
301
|
+
selectedBytesExceededMessage: 'BagIt exceeds its combined byte limit.', metadataMismatchMessage: 'BagIt ZIP metadata or CRC is invalid.',
|
|
302
|
+
inspect(entry, metadata) {
|
|
303
|
+
if (metadata.kind === 'special' || (metadata.kind === 'directory' && !entry.name.endsWith('/'))
|
|
304
|
+
|| (metadata.kind === 'file' && entry.name.endsWith('/')))
|
|
305
|
+
throw new TypeError('BagIt ZIP entries must be ordinary files or directories, not symbolic links or special files.');
|
|
306
|
+
admit({ path: entry.name, byteLength: entry.originalSize });
|
|
307
|
+
return { key: entry.name, selected: true, maximumBytes: bagItEntryMaximum(entry.name), exceededMessage: 'BagIt entry exceeds its byte limit.' };
|
|
308
|
+
},
|
|
309
|
+
}).files;
|
|
310
|
+
}
|
|
311
|
+
/** Creates SHA-512 by default; SHA-256 is also interoperable. */
|
|
312
|
+
export async function prepareBagItEntries(payload, tags = new Map(), algorithm = 'sha512') {
|
|
313
|
+
if (algorithm !== 'sha256' && algorithm !== 'sha512')
|
|
314
|
+
throw new UnsupportedBagItError('BagIt creation supports SHA-256 or SHA-512.');
|
|
315
|
+
if (!(payload instanceof Map) || payload.size > MAX_BAGIT_PAYLOAD_FILES || !(tags instanceof Map) || tags.size > MAX_BAGIT_TAG_FILES - 4)
|
|
316
|
+
throw new TypeError('BagIt creation exceeds its file limit.');
|
|
317
|
+
const files = new Map([['data/', new Uint8Array()]]);
|
|
318
|
+
for (const [path, bytes] of payload) {
|
|
319
|
+
if (!bagItPath(path).startsWith('data/') || path.endsWith('/'))
|
|
320
|
+
throw new TypeError('BagIt payload paths must be files below data/.');
|
|
321
|
+
files.set(path, bytes);
|
|
322
|
+
}
|
|
323
|
+
for (const [path, bytes] of tags) {
|
|
324
|
+
if (path.startsWith('data/') || path.endsWith('/') || ['bagit.txt', 'bag-info.txt', 'fetch.txt'].includes(path) || payloadManifest.test(path) || tagManifest.test(path))
|
|
325
|
+
throw new TypeError('BagIt additional tag conflicts with a format-owned file.');
|
|
326
|
+
files.set(path, bytes);
|
|
327
|
+
}
|
|
328
|
+
files.set('bagit.txt', encoder.encode('BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n'));
|
|
329
|
+
files.set('bag-info.txt', encoder.encode(`Payload-Oxum: ${[...payload.values()].reduce((sum, bytes) => sum + bytes.byteLength, 0)}.${payload.size}\n`));
|
|
330
|
+
const owned = capture(files);
|
|
331
|
+
const manifest = [];
|
|
332
|
+
for (const path of [...payload.keys()].sort(order))
|
|
333
|
+
manifest.push(`${await checksum(owned.get(path), algorithm)} ${path.replaceAll('%', '%25')}`);
|
|
334
|
+
owned.set(`manifest-${algorithm}.txt`, encoder.encode(manifest.length ? `${manifest.join('\n')}\n` : ''));
|
|
335
|
+
const tagRows = [];
|
|
336
|
+
for (const [path, bytes] of [...owned].sort(([left], [right]) => order(left, right)))
|
|
337
|
+
if (!path.startsWith('data/'))
|
|
338
|
+
tagRows.push(`${await checksum(bytes, algorithm)} ${path.replaceAll('%', '%25')}`);
|
|
339
|
+
owned.set(`tagmanifest-${algorithm}.txt`, encoder.encode(`${tagRows.join('\n')}\n`));
|
|
340
|
+
assertBagItSelection([...owned].map(([path, bytes]) => ({ path, byteLength: bytes.byteLength })));
|
|
341
|
+
return owned;
|
|
342
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { Unzip, UnzipInflate, unzipSync, } from 'fflate';
|
|
2
|
+
import { updateCrc32 } from "./crc32.mjs";
|
|
3
|
+
const INPUT_CHUNK_BYTES = 1_024;
|
|
4
|
+
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
5
|
+
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
6
|
+
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
7
|
+
const DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
|
|
8
|
+
const MAX_ZIP_COMMENT_BYTES = 0xffff;
|
|
9
|
+
function concat(chunks, total) {
|
|
10
|
+
const output = new Uint8Array(total);
|
|
11
|
+
let offset = 0;
|
|
12
|
+
for (const chunk of chunks) {
|
|
13
|
+
output.set(chunk, offset);
|
|
14
|
+
offset += chunk.byteLength;
|
|
15
|
+
}
|
|
16
|
+
return output;
|
|
17
|
+
}
|
|
18
|
+
function equalBytes(left, right) {
|
|
19
|
+
if (left.byteLength !== right.byteLength)
|
|
20
|
+
return false;
|
|
21
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
22
|
+
if (left[index] !== right[index])
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
function inspectZipDirectory(input, maximumEntries, mismatchMessage) {
|
|
28
|
+
if (input.byteLength < 22)
|
|
29
|
+
throw new Error(mismatchMessage);
|
|
30
|
+
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
31
|
+
const earliestEndRecord = Math.max(0, input.byteLength - 22 - MAX_ZIP_COMMENT_BYTES);
|
|
32
|
+
let endRecordOffset = -1;
|
|
33
|
+
for (let offset = input.byteLength - 22; offset >= earliestEndRecord; offset -= 1) {
|
|
34
|
+
if (view.getUint32(offset, true) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE)
|
|
35
|
+
continue;
|
|
36
|
+
const commentBytes = view.getUint16(offset + 20, true);
|
|
37
|
+
if (offset + 22 + commentBytes === input.byteLength) {
|
|
38
|
+
endRecordOffset = offset;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (endRecordOffset < 0)
|
|
43
|
+
throw new Error(mismatchMessage);
|
|
44
|
+
const disk = view.getUint16(endRecordOffset + 4, true);
|
|
45
|
+
const directoryDisk = view.getUint16(endRecordOffset + 6, true);
|
|
46
|
+
const diskEntries = view.getUint16(endRecordOffset + 8, true);
|
|
47
|
+
const entryCount = view.getUint16(endRecordOffset + 10, true);
|
|
48
|
+
const directoryBytes = view.getUint32(endRecordOffset + 12, true);
|
|
49
|
+
const directoryOffset = view.getUint32(endRecordOffset + 16, true);
|
|
50
|
+
if (disk !== 0
|
|
51
|
+
|| directoryDisk !== 0
|
|
52
|
+
|| diskEntries !== entryCount
|
|
53
|
+
|| entryCount === 0xffff
|
|
54
|
+
|| entryCount > maximumEntries
|
|
55
|
+
|| directoryBytes === 0xffff_ffff
|
|
56
|
+
|| directoryOffset === 0xffff_ffff
|
|
57
|
+
|| directoryOffset + directoryBytes !== endRecordOffset) {
|
|
58
|
+
throw new Error(mismatchMessage);
|
|
59
|
+
}
|
|
60
|
+
const entries = [];
|
|
61
|
+
let offset = directoryOffset;
|
|
62
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
63
|
+
if (offset > endRecordOffset - 46 || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
|
|
64
|
+
throw new Error(mismatchMessage);
|
|
65
|
+
}
|
|
66
|
+
const flags = view.getUint16(offset + 8, true);
|
|
67
|
+
const compression = view.getUint16(offset + 10, true);
|
|
68
|
+
const crc32 = view.getUint32(offset + 16, true);
|
|
69
|
+
const compressedSize = view.getUint32(offset + 20, true);
|
|
70
|
+
const originalSize = view.getUint32(offset + 24, true);
|
|
71
|
+
const nameBytes = view.getUint16(offset + 28, true);
|
|
72
|
+
const extraBytes = view.getUint16(offset + 30, true);
|
|
73
|
+
const commentBytes = view.getUint16(offset + 32, true);
|
|
74
|
+
const startingDisk = view.getUint16(offset + 34, true);
|
|
75
|
+
const host = view.getUint16(offset + 4, true) >>> 8;
|
|
76
|
+
const attributes = view.getUint32(offset + 38, true);
|
|
77
|
+
const unixType = host === 3 ? (attributes >>> 16) & 0xf000 : 0;
|
|
78
|
+
const kind = unixType === 0x8000 ? 'file' : unixType === 0x4000 ? 'directory'
|
|
79
|
+
: unixType !== 0 ? 'special' : (attributes & 0x10) !== 0 ? 'directory' : 'unspecified';
|
|
80
|
+
const localHeaderOffset = view.getUint32(offset + 42, true);
|
|
81
|
+
const nextOffset = offset + 46 + nameBytes + extraBytes + commentBytes;
|
|
82
|
+
if ((flags & 1) !== 0
|
|
83
|
+
|| compressedSize === 0xffff_ffff
|
|
84
|
+
|| originalSize === 0xffff_ffff
|
|
85
|
+
|| localHeaderOffset === 0xffff_ffff
|
|
86
|
+
|| startingDisk !== 0
|
|
87
|
+
|| nextOffset > endRecordOffset
|
|
88
|
+
|| localHeaderOffset > directoryOffset - 30
|
|
89
|
+
|| view.getUint32(localHeaderOffset, true) !== LOCAL_FILE_HEADER_SIGNATURE) {
|
|
90
|
+
throw new Error(mismatchMessage);
|
|
91
|
+
}
|
|
92
|
+
const localFlags = view.getUint16(localHeaderOffset + 6, true);
|
|
93
|
+
const localCompression = view.getUint16(localHeaderOffset + 8, true);
|
|
94
|
+
const localCrc32 = view.getUint32(localHeaderOffset + 14, true);
|
|
95
|
+
const localCompressedSize = view.getUint32(localHeaderOffset + 18, true);
|
|
96
|
+
const localOriginalSize = view.getUint32(localHeaderOffset + 22, true);
|
|
97
|
+
const localNameBytes = view.getUint16(localHeaderOffset + 26, true);
|
|
98
|
+
const localExtraBytes = view.getUint16(localHeaderOffset + 28, true);
|
|
99
|
+
const localDataOffset = localHeaderOffset + 30 + localNameBytes + localExtraBytes;
|
|
100
|
+
const dataEnd = localDataOffset + compressedSize;
|
|
101
|
+
if (localFlags !== flags
|
|
102
|
+
|| localCompression !== compression
|
|
103
|
+
|| localDataOffset > directoryOffset
|
|
104
|
+
|| dataEnd > directoryOffset
|
|
105
|
+
|| !equalBytes(input.subarray(offset + 46, offset + 46 + nameBytes), input.subarray(localHeaderOffset + 30, localHeaderOffset + 30 + localNameBytes))) {
|
|
106
|
+
throw new Error(mismatchMessage);
|
|
107
|
+
}
|
|
108
|
+
let localRegionEnd = dataEnd;
|
|
109
|
+
if ((flags & 8) !== 0) {
|
|
110
|
+
if ((localCrc32 !== 0 && localCrc32 !== crc32)
|
|
111
|
+
|| (localCompressedSize !== 0 && localCompressedSize !== compressedSize)
|
|
112
|
+
|| (localOriginalSize !== 0 && localOriginalSize !== originalSize)) {
|
|
113
|
+
throw new Error(mismatchMessage);
|
|
114
|
+
}
|
|
115
|
+
const unsignedDescriptorMatches = dataEnd <= directoryOffset - 12
|
|
116
|
+
&& view.getUint32(dataEnd, true) === crc32
|
|
117
|
+
&& view.getUint32(dataEnd + 4, true) === compressedSize
|
|
118
|
+
&& view.getUint32(dataEnd + 8, true) === originalSize;
|
|
119
|
+
const signedDescriptorMatches = dataEnd <= directoryOffset - 16
|
|
120
|
+
&& view.getUint32(dataEnd, true) === DATA_DESCRIPTOR_SIGNATURE
|
|
121
|
+
&& view.getUint32(dataEnd + 4, true) === crc32
|
|
122
|
+
&& view.getUint32(dataEnd + 8, true) === compressedSize
|
|
123
|
+
&& view.getUint32(dataEnd + 12, true) === originalSize;
|
|
124
|
+
if (!signedDescriptorMatches && !unsignedDescriptorMatches)
|
|
125
|
+
throw new Error(mismatchMessage);
|
|
126
|
+
localRegionEnd = dataEnd + (signedDescriptorMatches ? 16 : 12);
|
|
127
|
+
}
|
|
128
|
+
else if (localCrc32 !== crc32
|
|
129
|
+
|| localCompressedSize !== compressedSize
|
|
130
|
+
|| localOriginalSize !== originalSize) {
|
|
131
|
+
throw new Error(mismatchMessage);
|
|
132
|
+
}
|
|
133
|
+
entries.push(Object.freeze({
|
|
134
|
+
kind,
|
|
135
|
+
crc32,
|
|
136
|
+
compression,
|
|
137
|
+
compressedSize,
|
|
138
|
+
originalSize,
|
|
139
|
+
localHeaderOffset,
|
|
140
|
+
localRegionEnd,
|
|
141
|
+
}));
|
|
142
|
+
offset = nextOffset;
|
|
143
|
+
}
|
|
144
|
+
if (offset !== endRecordOffset)
|
|
145
|
+
throw new Error(mismatchMessage);
|
|
146
|
+
const localOrder = [...entries].sort((left, right) => left.localHeaderOffset - right.localHeaderOffset);
|
|
147
|
+
for (let index = 0; index < localOrder.length; index += 1) {
|
|
148
|
+
const entry = localOrder[index];
|
|
149
|
+
const next = localOrder[index + 1];
|
|
150
|
+
if (entry.localRegionEnd > (next?.localHeaderOffset ?? directoryOffset)) {
|
|
151
|
+
throw new Error(mismatchMessage);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return Object.freeze(entries);
|
|
155
|
+
}
|
|
156
|
+
function extractBoundedZipEntries(input, options) {
|
|
157
|
+
if (!(input instanceof Uint8Array)
|
|
158
|
+
|| !Number.isSafeInteger(options.maximumEntries)
|
|
159
|
+
|| options.maximumEntries < 1
|
|
160
|
+
|| !Number.isSafeInteger(options.maximumSelectedBytes)
|
|
161
|
+
|| options.maximumSelectedBytes < 0) {
|
|
162
|
+
throw new TypeError('Bounded ZIP extraction received invalid input.');
|
|
163
|
+
}
|
|
164
|
+
const directory = inspectZipDirectory(input, options.maximumEntries, options.metadataMismatchMessage);
|
|
165
|
+
const plans = new Map();
|
|
166
|
+
let directoryIndex = 0;
|
|
167
|
+
unzipSync(input, {
|
|
168
|
+
filter(info) {
|
|
169
|
+
const directoryEntry = directory[directoryIndex];
|
|
170
|
+
directoryIndex += 1;
|
|
171
|
+
if (!directoryEntry
|
|
172
|
+
|| info.compression !== directoryEntry.compression
|
|
173
|
+
|| info.size !== directoryEntry.compressedSize
|
|
174
|
+
|| info.originalSize !== directoryEntry.originalSize) {
|
|
175
|
+
throw new Error(options.metadataMismatchMessage);
|
|
176
|
+
}
|
|
177
|
+
const selection = options.inspect(info, { kind: directoryEntry.kind });
|
|
178
|
+
if (!selection
|
|
179
|
+
|| typeof selection.key !== 'string'
|
|
180
|
+
|| !selection.key
|
|
181
|
+
|| typeof selection.selected !== 'boolean'
|
|
182
|
+
|| !Number.isSafeInteger(selection.maximumBytes)
|
|
183
|
+
|| selection.maximumBytes < 0
|
|
184
|
+
|| typeof selection.exceededMessage !== 'string'
|
|
185
|
+
|| !selection.exceededMessage) {
|
|
186
|
+
throw new TypeError('Bounded ZIP extraction received an invalid entry plan.');
|
|
187
|
+
}
|
|
188
|
+
if (plans.has(selection.key))
|
|
189
|
+
throw new Error(options.metadataMismatchMessage);
|
|
190
|
+
plans.set(selection.key, Object.freeze({ info, directory: directoryEntry, selection }));
|
|
191
|
+
return false;
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (directoryIndex !== directory.length)
|
|
195
|
+
throw new Error(options.metadataMismatchMessage);
|
|
196
|
+
const files = new Map();
|
|
197
|
+
const localEntries = new Set();
|
|
198
|
+
let selectedBytes = 0;
|
|
199
|
+
let failure = null;
|
|
200
|
+
const unzip = new Unzip((file) => {
|
|
201
|
+
try {
|
|
202
|
+
const key = options.keyForName(file.name);
|
|
203
|
+
const plan = plans.get(key);
|
|
204
|
+
if (!plan
|
|
205
|
+
|| localEntries.has(key)
|
|
206
|
+
|| file.name !== plan.info.name
|
|
207
|
+
|| file.compression !== plan.info.compression
|
|
208
|
+
|| (file.size !== undefined && file.size !== plan.info.size)
|
|
209
|
+
|| (file.originalSize !== undefined && file.originalSize !== plan.info.originalSize)) {
|
|
210
|
+
throw new Error(options.metadataMismatchMessage);
|
|
211
|
+
}
|
|
212
|
+
localEntries.add(key);
|
|
213
|
+
// Unselected entries are structurally inspected but are not decompressed or
|
|
214
|
+
// CRC-validated because their payload is outside the caller's import scope.
|
|
215
|
+
if (!plan.selection.selected)
|
|
216
|
+
return;
|
|
217
|
+
const chunks = [];
|
|
218
|
+
let entryBytes = 0;
|
|
219
|
+
let crc32State = 0xffff_ffff;
|
|
220
|
+
file.ondata = (error, chunk, final) => {
|
|
221
|
+
if (failure)
|
|
222
|
+
throw failure;
|
|
223
|
+
if (error || !(chunk instanceof Uint8Array)) {
|
|
224
|
+
failure = error || new Error(options.metadataMismatchMessage);
|
|
225
|
+
file.terminate();
|
|
226
|
+
throw failure;
|
|
227
|
+
}
|
|
228
|
+
if (chunk.byteLength > plan.selection.maximumBytes - entryBytes) {
|
|
229
|
+
failure = new Error(plan.selection.exceededMessage);
|
|
230
|
+
file.terminate();
|
|
231
|
+
throw failure;
|
|
232
|
+
}
|
|
233
|
+
if (chunk.byteLength > options.maximumSelectedBytes - selectedBytes) {
|
|
234
|
+
failure = new Error(options.selectedBytesExceededMessage);
|
|
235
|
+
file.terminate();
|
|
236
|
+
throw failure;
|
|
237
|
+
}
|
|
238
|
+
entryBytes += chunk.byteLength;
|
|
239
|
+
selectedBytes += chunk.byteLength;
|
|
240
|
+
crc32State = updateCrc32(crc32State, chunk);
|
|
241
|
+
if (chunk.byteLength)
|
|
242
|
+
chunks.push(chunk.slice());
|
|
243
|
+
if (final) {
|
|
244
|
+
const actualCrc32 = (crc32State ^ 0xffff_ffff) >>> 0;
|
|
245
|
+
if (entryBytes !== plan.info.originalSize || actualCrc32 !== plan.directory.crc32) {
|
|
246
|
+
failure = new Error(options.metadataMismatchMessage);
|
|
247
|
+
file.terminate();
|
|
248
|
+
throw failure;
|
|
249
|
+
}
|
|
250
|
+
files.set(plan.info.name, concat(chunks, entryBytes));
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
file.start();
|
|
254
|
+
}
|
|
255
|
+
catch (cause) {
|
|
256
|
+
failure = cause;
|
|
257
|
+
throw cause;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
unzip.register(UnzipInflate);
|
|
261
|
+
try {
|
|
262
|
+
for (let offset = 0; offset < input.byteLength; offset += INPUT_CHUNK_BYTES) {
|
|
263
|
+
const end = Math.min(input.byteLength, offset + INPUT_CHUNK_BYTES);
|
|
264
|
+
unzip.push(input.subarray(offset, end), end === input.byteLength);
|
|
265
|
+
if (failure)
|
|
266
|
+
throw failure;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (cause) {
|
|
270
|
+
throw failure || cause;
|
|
271
|
+
}
|
|
272
|
+
const selectedCount = [...plans.values()].filter((plan) => plan.selection.selected).length;
|
|
273
|
+
if (localEntries.size !== plans.size || files.size !== selectedCount) {
|
|
274
|
+
throw new Error(options.metadataMismatchMessage);
|
|
275
|
+
}
|
|
276
|
+
return Object.freeze({ files, entryCount: plans.size });
|
|
277
|
+
}
|
|
278
|
+
export { extractBoundedZipEntries };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const TABLE = (() => {
|
|
2
|
+
const table = new Uint32Array(256);
|
|
3
|
+
for (let index = 0; index < table.length; index += 1) {
|
|
4
|
+
let value = index;
|
|
5
|
+
for (let bit = 0; bit < 8; bit += 1)
|
|
6
|
+
value = (value & 1) !== 0 ? 0xedb8_8320 ^ (value >>> 1) : value >>> 1;
|
|
7
|
+
table[index] = value >>> 0;
|
|
8
|
+
}
|
|
9
|
+
return table;
|
|
10
|
+
})();
|
|
11
|
+
/** Incremental ZIP/GZIP CRC state; initialise and finalise with 0xffffffff. */
|
|
12
|
+
export function updateCrc32(state, bytes) {
|
|
13
|
+
let next = state >>> 0;
|
|
14
|
+
for (const byte of bytes)
|
|
15
|
+
next = TABLE[(next ^ byte) & 0xff] ^ (next >>> 8);
|
|
16
|
+
return next >>> 0;
|
|
17
|
+
}
|