@taucad/archive-core 0.0.0-stage → 0.1.0-beta.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0-beta.0
4
+
5
+ - Initial scaffold.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # @taucad/archive-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@taucad/archive-core)](https://www.npmjs.com/package/@taucad/archive-core)
4
+ [![downloads](https://img.shields.io/npm/dm/@taucad/archive-core)](https://www.npmjs.com/package/@taucad/archive-core)
5
+ [![size](https://img.shields.io/npm/unpacked-size/@taucad/archive-core)](https://www.npmjs.com/package/@taucad/archive-core)
6
+ [![license](https://img.shields.io/npm/l/@taucad/archive-core)](./LICENSE)
7
+ [![provenance](https://img.shields.io/badge/provenance-npm-blue)](https://docs.npmjs.com/generating-provenance-statements)
8
+
9
+ Verified archive acquisition and tar, zip and wheel readers for Tau payloads
10
+
11
+ ## Why @taucad/archive-core?
12
+
13
+ - **Verify before trusting** — `fetchVerified` checks every download against an SRI pin before any
14
+ byte reaches a reader, with bounded retries, an optional redirect allow-list and no credentials.
15
+ - **Refuse rather than repair** — escaping paths, links that leave the extraction root, device and
16
+ hardlink entries, duplicate members and RECORD disagreements are typed `ArchiveError` refusals, not
17
+ warnings.
18
+ - **Caps are inputs** — every reader takes `maximumUnpackedBytes` and `maximumEntries` from its
19
+ caller, so each payload kind chooses its own limits.
20
+ - **No runtime dependency** — the readers run in a browser worker or Node.js; only `./node` touches
21
+ the filesystem.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm i @taucad/archive-core
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```typescript
32
+ import { fetchVerified, gunzip, readTarEntries } from '@taucad/archive-core';
33
+ import { writeArchiveEntries } from '@taucad/archive-core/node';
34
+
35
+ const limits = { maximumUnpackedBytes: 512 * 1024 * 1024, maximumEntries: 50_000 };
36
+ const bytes = await fetchVerified({ url, integrity: 'sha256-…', byteLength, signal });
37
+ const { entries } = readTarEntries(await gunzip(bytes, limits), { ...limits, stripComponents: 1 });
38
+ const files = await writeArchiveEntries({ root, destination: 'python', entries, signal });
39
+ ```
40
+
41
+ `files` lists each written path with its byte length, its sha256 (computed from the bytes written)
42
+ and its mode, which is the input to a closure receipt.
43
+
44
+ ## API
45
+
46
+ | Export | Purpose |
47
+ | ------------------------------- | ------------------------------------------------------------------------- |
48
+ | `fetchVerified` | download with retries and an SRI check; `fetch` is injectable |
49
+ | `integrityMatches` | compare bytes with an npm-style SRI string (strongest algorithm decides) |
50
+ | `integrityFromHex` | turn a published hex digest into a canonical SRI string |
51
+ | `integrityToHex` | turn a canonical SRI string back into its algorithm and lowercase hex |
52
+ | `gunzip` | bounded gzip decompression |
53
+ | `readTarEntries` | ustar, pax and GNU long names; `stripComponents`; contained symlinks |
54
+ | `readZipEntries` | stored and deflated members, ZIP64, CRC-32 checked |
55
+ | `readWheel` | a wheel checked against its RECORD, with the `data` scheme returned apart |
56
+ | `findCaseCollisions` | paths that one case-insensitive filesystem would merge |
57
+ | `ArchiveError` | typed refusal with `code`, `reason` and `path` |
58
+ | `writeArchiveEntries` (`/node`) | exclusive-create writes that hash while writing; links kept or copied |
59
+
60
+ ## Environment
61
+
62
+ | Host | Supported | Notes |
63
+ | -------------- | --------- | ----------------------------------------------------------------- |
64
+ | Browser worker | Yes | the root entry uses `fetch`, Web Crypto and `DecompressionStream` |
65
+ | Node.js | Yes | `>=24`; `@taucad/archive-core/node` writes to disk |
66
+
67
+ ## Versioning and stability
68
+
69
+ Pre-1.0: a minor version may break. Pin `~0.1.0` rather than `^0.1.0`. This package releases in the
70
+ fixed version group with the other `@taucad` packages.
71
+ See [version-policy.md](https://github.com/taucad/tau/blob/main/docs/policy/version-policy.md).
72
+
73
+ ## Security and provenance
74
+
75
+ Every release is published from GitHub Actions with npm trusted publishing and
76
+ [provenance](https://docs.npmjs.com/generating-provenance-statements). Verify a downloaded tree:
77
+
78
+ ```bash
79
+ npm audit signatures
80
+ ```
81
+
82
+ ## License
83
+
84
+ Apache-2.0 — see [LICENSE](./LICENSE).
85
+
86
+ ## Links
87
+
88
+ - [Documentation](https://tau.new/docs/runtime)
89
+ - [Source](https://github.com/taucad/tau/tree/main/packages/core/archive)
90
+ - [Changelog](https://github.com/taucad/tau/blob/main/packages/core/archive/CHANGELOG.md)
91
+ - [Issues](https://github.com/taucad/tau/issues)
@@ -0,0 +1,53 @@
1
+ import { ArchiveLimits } from "./bounded-stream.mjs";
2
+ //#region src/archive-entry.d.ts
3
+ /**
4
+ * One extracted entry, relative to the extraction root, with POSIX separators.
5
+ * @public
6
+ */
7
+ type ArchiveEntry = {
8
+ readonly path: string;
9
+ /** File contents; empty for a symlink. */
10
+ readonly bytes: Uint8Array<ArrayBuffer>;
11
+ /** POSIX permission bits as recorded by the archive; absent when the archive records none. */
12
+ readonly mode?: number;
13
+ /** Set for a symlink whose target resolves inside the extraction root; relative, as the archive spells it. */
14
+ readonly symlinkTarget?: string;
15
+ };
16
+ /**
17
+ * The entries of an archive plus the names it did not extract.
18
+ * @public
19
+ */
20
+ type ArchiveContents = {
21
+ readonly entries: readonly ArchiveEntry[];
22
+ /** Entries above the stripped prefix, and every link or special file when `links` is `'skip'`. */
23
+ readonly skipped: readonly string[];
24
+ };
25
+ /**
26
+ * How a reader treats links and special files.
27
+ *
28
+ * - `'contained'`: keep symlinks whose target stays inside the extraction root; refuse escaping links, hardlinks,
29
+ * devices and FIFOs.
30
+ * - `'skip'`: extract regular files only and list every other entry in `skipped` (what npm does).
31
+ * @public
32
+ */
33
+ type ArchiveLinkPolicy = 'contained' | 'skip';
34
+ /**
35
+ * Options of the tar and zip readers.
36
+ * @public
37
+ */
38
+ type ReadArchiveOptions = ArchiveLimits & {
39
+ /** Leading path segments dropped from every entry name; entries with no segment left are skipped. Default 0. */
40
+ readonly stripComponents?: number;
41
+ /** Default `'contained'`. */
42
+ readonly links?: ArchiveLinkPolicy;
43
+ };
44
+ /**
45
+ * Pairs of paths that differ only by case. A case-insensitive volume (the macOS and Windows defaults) would
46
+ * merge each pair into one file, so a manifest generator rejects a closure that has any.
47
+ * @param paths - Root-relative POSIX paths.
48
+ * @returns Each colliding pair, the earlier path first, in input order.
49
+ * @public
50
+ */
51
+ declare const findCaseCollisions: (paths: Iterable<string>) => Array<readonly [string, string]>;
52
+ //#endregion
53
+ export { ArchiveContents, ArchiveEntry, ArchiveLinkPolicy, ReadArchiveOptions, findCaseCollisions };
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";const t=t=>{let n=t.split(`/`);if(t.length===0||t.startsWith(`/`)||t.includes(`\\`)||n.includes(`..`))throw new e(`EXTRACTION_REFUSED`,`Archive entry path '${t}' escapes the extraction root.`,{reason:`unsafe-path`,path:t});return n.filter(e=>e.length>0&&e!==`.`)},n=(e,t)=>{if(t.length===0||t.startsWith(`/`)||t.includes(`\\`)||t.includes(`\0`))return;let n=e.split(`/`).slice(0,-1);for(let e of t.split(`/`))if(e===`..`){if(n.length===0)return;n.pop()}else e.length>0&&e!==`.`&&n.push(e);return n.length===0?void 0:n.join(`/`)},r=e=>{let t=new Map,n=[];for(let r of e){let e=r.toLowerCase(),i=t.get(e);i===void 0?t.set(e,r):i!==r&&n.push([i,r])}return n},i=(t,n,r)=>{if(t>n)throw new e(`EXTRACTION_REFUSED`,`${r} has more than ${n} entries.`,{reason:`limit`})};export{i as assertEntryCount,t as entrySegments,r as findCaseCollisions,n as resolveLinkTarget};
@@ -0,0 +1,42 @@
1
+ //#region src/archive-error.d.ts
2
+ /**
3
+ * Stable failure classes of `@taucad/archive-core`. Nothing is written when any of them is thrown.
4
+ *
5
+ * - `DOWNLOAD_FAILED`: no attempt returned the declared bytes (HTTP status, transport error, length, redirect).
6
+ * - `INTEGRITY_MISMATCH`: the bytes do not match their SRI pin, or a wheel member does not match its `RECORD` row.
7
+ * - `EXTRACTION_REFUSED`: a hostile or malformed entry, an escaping link, a special file or an exceeded cap.
8
+ * - `UNSUPPORTED_FEATURE`: a well-formed archive uses something this reader does not install.
9
+ * @public
10
+ */
11
+ type ArchiveErrorCode = 'DOWNLOAD_FAILED' | 'INTEGRITY_MISMATCH' | 'EXTRACTION_REFUSED' | 'UNSUPPORTED_FEATURE';
12
+ /**
13
+ * Refines an {@link ArchiveErrorCode} so a host can phrase its own message without parsing text.
14
+ * @public
15
+ */
16
+ type ArchiveErrorReason = 'http-status' | 'transport' | 'byte-length' | 'redirect' | 'digest' | 'record' | 'unsafe-path' | 'escaping-link' | 'special-entry' | 'duplicate-path' | 'limit' | 'malformed' | 'data-scheme' | 'compression-method' | 'encryption' | 'zip-symlink';
17
+ /**
18
+ * Options of {@link ArchiveError}.
19
+ * @public
20
+ */
21
+ type ArchiveErrorOptions = {
22
+ readonly reason: ArchiveErrorReason;
23
+ /** The archive entry or URL the failure is about. */
24
+ readonly path?: string;
25
+ /** The raw transport or format detail, such as `HTTP 404`. */
26
+ readonly detail?: string;
27
+ readonly cause?: unknown;
28
+ };
29
+ /**
30
+ * A typed archive failure with a stable `code` and `reason`.
31
+ * @public
32
+ */
33
+ declare class ArchiveError extends Error {
34
+ readonly name = "ArchiveError";
35
+ readonly code: ArchiveErrorCode;
36
+ readonly reason: ArchiveErrorReason;
37
+ readonly path: string | undefined;
38
+ readonly detail: string | undefined;
39
+ constructor(code: ArchiveErrorCode, message: string, options: ArchiveErrorOptions);
40
+ }
41
+ //#endregion
42
+ export { ArchiveError, ArchiveErrorCode, ArchiveErrorOptions, ArchiveErrorReason };
@@ -0,0 +1 @@
1
+ var e=class extends Error{name=`ArchiveError`;code;reason;path;detail;constructor(e,t,n){super(t,n.cause===void 0?void 0:{cause:n.cause}),this.code=e,this.reason=n.reason,this.path=n.path,this.detail=n.detail}};export{e as ArchiveError};
@@ -0,0 +1,23 @@
1
+ //#region src/bounded-stream.d.ts
2
+ /**
3
+ * Caps every reader takes as input; nothing in this package assumes a default size.
4
+ * @public
5
+ */
6
+ type ArchiveLimits = {
7
+ /** Largest accepted total of decompressed or extracted bytes. */
8
+ readonly maximumUnpackedBytes: number;
9
+ /** Largest accepted number of archive entries, skipped ones included. */
10
+ readonly maximumEntries: number;
11
+ };
12
+ /**
13
+ * Decompress gzip bytes with the platform `DecompressionStream`, bounded by the caller's cap.
14
+ * @param bytes - Gzip-compressed bytes, such as an npm tarball or a python-build-standalone archive.
15
+ * @param limits - `maximumUnpackedBytes` bounds the decompressed output.
16
+ * @returns The decompressed bytes.
17
+ * @throws {TypeError} When the bytes are not gzip.
18
+ * @throws {ArchiveError} `EXTRACTION_REFUSED` (`limit`) when the output exceeds the cap.
19
+ * @public
20
+ */
21
+ declare const gunzip: (bytes: Uint8Array<ArrayBuffer>, limits: Pick<ArchiveLimits, "maximumUnpackedBytes">) => Promise<Uint8Array<ArrayBuffer>>;
22
+ //#endregion
23
+ export { ArchiveLimits, gunzip };
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";const t=async(t,n)=>{let r=t.getReader(),i=[],a=0;try{for(;;){let t=await r.read();if(t.done)break;if(a+=t.value.byteLength,a>n)throw await r.cancel(),new e(`EXTRACTION_REFUSED`,`Stream exceeds ${n} bytes.`,{reason:`limit`});i.push(t.value)}}finally{r.releaseLock()}let o=new Uint8Array(a),s=0;for(let e of i)o.set(e,s),s+=e.byteLength;return o},n=async(e,n,r)=>{let i=new DecompressionStream(n),a=i.writable.getWriter(),o=(async()=>{await a.write(e),await a.close()})();try{return await t(i.readable,r)}finally{await o.catch(()=>void 0)}},r=async(e,t)=>n(e,`gzip`,t.maximumUnpackedBytes),i=async(e,t,n)=>{let r=Array.from({length:e.length}),i=0;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(;i<e.length;){let t=i;i+=1,r[t]=await n(e[t],t)}})),r};export{n as decompress,r as gunzip,i as mapConcurrent,t as readBoundedStream};
@@ -0,0 +1,42 @@
1
+ //#region src/fetch-verified.d.ts
2
+ /**
3
+ * Redirect policy of {@link fetchVerified}. When given, redirects are followed by hand: only `https:` targets the
4
+ * predicate admits, at most `maximum` hops. When absent, the platform follows redirects (a browser cannot read them).
5
+ * @public
6
+ */
7
+ type FetchRedirectPolicy = {
8
+ readonly maximum: number;
9
+ /** Whether a redirect target may be followed. */
10
+ readonly allow: (url: URL) => boolean;
11
+ };
12
+ /**
13
+ * Input of {@link fetchVerified}.
14
+ * @public
15
+ */
16
+ type FetchVerifiedInput = {
17
+ readonly url: string;
18
+ /** SRI string the bytes must match; see `integrityMatches`. */
19
+ readonly integrity: string;
20
+ /** Declared size: a longer or shorter body fails the attempt before hashing. */
21
+ readonly byteLength?: number;
22
+ /** Upper bound on the body when `byteLength` is not declared. */
23
+ readonly maximumBytes?: number;
24
+ readonly redirect?: FetchRedirectPolicy;
25
+ /** Transport; defaults to the global `fetch`. */
26
+ readonly fetch?: typeof fetch;
27
+ readonly signal: AbortSignal;
28
+ };
29
+ /**
30
+ * Download bytes and verify them against their SRI pin before returning them. Transport errors, 429 and 5xx are
31
+ * retried (three attempts, 100 ms then 200 ms apart); other statuses are not. An attempt fails once 60 s pass with
32
+ * neither a response nor a body chunk, so a slow body that keeps arriving is never cut off. No credentials are sent.
33
+ * Nothing is returned unless the whole body matched `integrity`.
34
+ * @param input - URL, pin, size policy, redirect policy, transport and signal.
35
+ * @returns The verified bytes.
36
+ * @throws {ArchiveError} `DOWNLOAD_FAILED` when no attempt returned an acceptable body; `INTEGRITY_MISMATCH` when it
37
+ * did not match `integrity`. An abort rejects with the signal's reason.
38
+ * @public
39
+ */
40
+ declare const fetchVerified: (input: FetchVerifiedInput) => Promise<Uint8Array<ArrayBuffer>>;
41
+ //#endregion
42
+ export { FetchRedirectPolicy, FetchVerifiedInput, fetchVerified };
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{readBoundedStream as t}from"./bounded-stream.mjs";import{integrityMatches as n}from"./integrity.mjs";const r=e=>e instanceof Error?e.message:String(e),i=async e=>new Promise(t=>{setTimeout(t,e)}),a=(t,n,r)=>new e(`DOWNLOAD_FAILED`,`Download of ${t.url} failed (${n}).`,{reason:r,path:t.url,detail:n}),o=e=>{let t=new AbortController,n,r=()=>{clearTimeout(n)},i=()=>{r(),n=setTimeout(()=>{t.abort(new DOMException(`no bytes received for ${e} ms`,`TimeoutError`))},e)};return i(),{signal:t.signal,progress:i,stop:r}},s=async(e,t)=>{let n=e.fetch??fetch;if(e.redirect===void 0)return n(e.url,{signal:t,credentials:`omit`});let r=new URL(e.url);for(let i=0;;i+=1){let o=await n(r,{signal:t,credentials:`omit`,redirect:`manual`}),s=o.headers.get(`location`);if(o.status<300||o.status>=400||s===null)return o;let c=new URL(s,r);if(i>=e.redirect.maximum||c.protocol!==`https:`||!e.redirect.allow(c))throw await o.body?.cancel(),a(e,`redirect to ${c.origin} refused`,`redirect`);await o.body?.cancel(),r=c}},c=async c=>{let l=c.byteLength??c.maximumBytes??1/0,u=a(c,`no response`,`transport`),d;for(let n=0;n<3;n+=1){n>0&&(await i(100*2**(n-1)),c.signal.throwIfAborted());let f=o(6e4);try{let e=await s(c,AbortSignal.any([c.signal,f.signal]));if(e.ok&&e.body!==null){let n=new TransformStream({transform(e,t){f.progress(),t.enqueue(e)}}),r=await t(e.body.pipeThrough(n),l);if(c.byteLength!==void 0&&r.byteLength!==c.byteLength)throw a(c,`${r.byteLength} of ${c.byteLength} bytes`,`byte-length`);d=r;break}if(u=a(c,`HTTP ${e.status}`,`http-status`),e.status!==429&&e.status<500)break}catch(t){if(c.signal.throwIfAborted(),t instanceof e&&t.reason===`redirect`)throw t;u=t instanceof e&&t.code===`DOWNLOAD_FAILED`?t:a(c,r(t),`transport`)}finally{f.stop()}}if(d===void 0)throw u;if(!await n(d,c.integrity))throw new e(`INTEGRITY_MISMATCH`,`${c.url} does not match its pinned integrity.`,{reason:`digest`,path:c.url});return d};export{c as fetchVerified};
@@ -0,0 +1,9 @@
1
+ import { ArchiveError, ArchiveErrorCode, ArchiveErrorOptions, ArchiveErrorReason } from "./archive-error.mjs";
2
+ import { ArchiveLimits, gunzip } from "./bounded-stream.mjs";
3
+ import { ArchiveContents, ArchiveEntry, ArchiveLinkPolicy, ReadArchiveOptions, findCaseCollisions } from "./archive-entry.mjs";
4
+ import { FetchRedirectPolicy, FetchVerifiedInput, fetchVerified } from "./fetch-verified.mjs";
5
+ import { IntegrityAlgorithm, integrityFromHex, integrityMatches, integrityToHex } from "./integrity.mjs";
6
+ import { readTarEntries } from "./tar.mjs";
7
+ import { WheelContents, readWheel } from "./wheel.mjs";
8
+ import { readZipEntries } from "./zip.mjs";
9
+ export { type ArchiveContents, type ArchiveEntry, ArchiveError, type ArchiveErrorCode, type ArchiveErrorOptions, type ArchiveErrorReason, type ArchiveLimits, type ArchiveLinkPolicy, type FetchRedirectPolicy, type FetchVerifiedInput, type IntegrityAlgorithm, type ReadArchiveOptions, type WheelContents, fetchVerified, findCaseCollisions, gunzip, integrityFromHex, integrityMatches, integrityToHex, readTarEntries, readWheel, readZipEntries };
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{findCaseCollisions as t}from"./archive-entry.mjs";import{gunzip as n}from"./bounded-stream.mjs";import{integrityFromHex as r,integrityMatches as i,integrityToHex as a}from"./integrity.mjs";import{fetchVerified as o}from"./fetch-verified.mjs";import{readTarEntries as s}from"./tar.mjs";import{readZipEntries as c}from"./zip.mjs";import{readWheel as l}from"./wheel.mjs";export{e as ArchiveError,o as fetchVerified,t as findCaseCollisions,n as gunzip,r as integrityFromHex,i as integrityMatches,a as integrityToHex,s as readTarEntries,l as readWheel,c as readZipEntries};
@@ -0,0 +1,39 @@
1
+ //#region src/integrity.d.ts
2
+ /**
3
+ * Digest algorithms a pinned payload may use in its SRI string.
4
+ * @public
5
+ */
6
+ type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512';
7
+ /**
8
+ * Whether bytes match an SRI string (`package-lock.json` `integrity`). The strongest listed algorithm among
9
+ * sha512, sha384, sha256 and npm's legacy sha1 decides; a string with none of them never matches.
10
+ * @param bytes - Bytes to check.
11
+ * @param integrity - One or more whitespace-separated `<algorithm>-<base64>` tokens.
12
+ * @returns `true` when the strongest algorithm's digest matches one of its tokens.
13
+ * @public
14
+ */
15
+ declare const integrityMatches: (bytes: Uint8Array<ArrayBuffer>, integrity: string) => Promise<boolean>;
16
+ /**
17
+ * Transcode a publisher's hex digest (PyPI, python-build-standalone `SHA256SUMS`, Microsoft `releases.json`) into
18
+ * an SRI string. Upper- and lower-case hex are accepted; the length must match the algorithm.
19
+ * @param algorithm - The digest's algorithm.
20
+ * @param hex - The digest in hexadecimal.
21
+ * @returns `<algorithm>-<padded base64>`.
22
+ * @throws {TypeError} When `hex` is not a digest of that algorithm's length.
23
+ * @public
24
+ */
25
+ declare const integrityFromHex: (algorithm: IntegrityAlgorithm, hex: string) => string;
26
+ /**
27
+ * Decode one SRI token back to the publisher's form: the algorithm and the lowercase hex digest, which is also
28
+ * how digest-keyed store paths spell it.
29
+ * @param integrity - A single `sha256-`, `sha384-` or `sha512-` token with padded base64 of the exact length.
30
+ * @returns The algorithm and lowercase hex digest.
31
+ * @throws {TypeError} When `integrity` is not one such token.
32
+ * @public
33
+ */
34
+ declare const integrityToHex: (integrity: string) => {
35
+ readonly algorithm: IntegrityAlgorithm;
36
+ readonly hex: string;
37
+ };
38
+ //#endregion
39
+ export { IntegrityAlgorithm, integrityFromHex, integrityMatches, integrityToHex };
@@ -0,0 +1 @@
1
+ const e={sha512:`SHA-512`,sha384:`SHA-384`,sha256:`SHA-256`,sha1:`SHA-1`},t={sha256:32,sha384:48,sha512:64},n=e=>btoa(String.fromCodePoint(...e)),r=async(t,r)=>{let i=r.split(/\s+/u).map(e=>/^(sha512|sha384|sha256|sha1)-([A-Za-z\d+/]+={0,2})$/u.exec(e)).filter(e=>e!==null),a=Object.entries(e).find(([e])=>i.some(([,t])=>t===e));if(a===void 0)return!1;let[o,s]=a,c=n(new Uint8Array(await crypto.subtle.digest(s,t)));return i.some(([,e,t])=>e===o&&t===c)},i=(e,r)=>{let i=t[e];if(!RegExp(`^[\\da-fA-F]{${i*2}}$`,`u`).test(r))throw TypeError(`'${r}' is not a ${i*8}-bit hex digest for ${e}.`);let a=new Uint8Array(i);for(let e=0;e<i;e+=1)a[e]=Number.parseInt(r.slice(e*2,e*2+2),16);return`${e}-${n(a)}`},a=e=>{let r=/^(sha256|sha384|sha512)-([A-Za-z\d+/]+={0,2})$/u.exec(e),i=r?.[1],a=r?.[2]??``,o=i===void 0||a.length%4!=0?``:atob(a);if(i===void 0||o.length!==t[i]||n(Uint8Array.from(o,e=>e.codePointAt(0)??0))!==a)throw TypeError(`'${e}' is not a sha256, sha384 or sha512 SRI string.`);return{algorithm:i,hex:Array.from(o,e=>(e.codePointAt(0)??0).toString(16).padStart(2,`0`)).join(``)}};export{i as integrityFromHex,r as integrityMatches,a as integrityToHex};
@@ -0,0 +1,45 @@
1
+ import { ArchiveEntry } from "./archive-entry.mjs";
2
+ //#region src/node.d.ts
3
+ /**
4
+ * One written file as a receipt records it.
5
+ * @public
6
+ */
7
+ type WrittenFile = {
8
+ /** POSIX path relative to `root`, whatever the host's separator. */
9
+ readonly path: string;
10
+ readonly byteLength: number;
11
+ /** Lowercase hex sha256 of the bytes written; for a symlink, of the file it resolves to. */
12
+ readonly sha256: string;
13
+ /** Permission bits applied (POSIX) or recorded (Windows); for a symlink, its target's. */
14
+ readonly mode: number;
15
+ readonly symlinkTarget?: string;
16
+ };
17
+ /**
18
+ * Input of {@link writeArchiveEntries}.
19
+ * @public
20
+ */
21
+ type WriteArchiveEntriesInput = {
22
+ /** Absolute directory everything is written under; created when missing. */
23
+ readonly root: string;
24
+ /** POSIX directory under `root` the entries are relative to; default the root itself. */
25
+ readonly destination?: string;
26
+ readonly entries: readonly ArchiveEntry[];
27
+ /** `'symlink'` creates links (the POSIX default); `'copy'` writes each link's resolved file instead (the Windows default). */
28
+ readonly links?: 'symlink' | 'copy';
29
+ readonly signal?: AbortSignal;
30
+ };
31
+ /**
32
+ * Write extracted entries below `root`, hashing each file's bytes as they are written, so the returned rows are
33
+ * the receipt of exactly what reached the disk. Every path must stay inside the root; files are created
34
+ * exclusively, so a path written twice (by two payloads, or merged by a case-insensitive volume) is refused.
35
+ * Modes are applied on POSIX and recorded on Windows; Windows paths use the `\\?\` namespace; a symlink must
36
+ * resolve inside the entry set to a regular file and is copied as that file when `links` is `'copy'`.
37
+ * @param input - Root, destination, entries, link handling and signal.
38
+ * @returns One row per entry, in input order, with POSIX root-relative paths and lowercase hex sha256.
39
+ * @throws {ArchiveError} `EXTRACTION_REFUSED` for an unsafe path (`unsafe-path`), a dangling, escaping or looping
40
+ * link (`escaping-link`) or a path that already exists (`duplicate-path`).
41
+ * @public
42
+ */
43
+ declare const writeArchiveEntries: (input: WriteArchiveEntriesInput) => Promise<WrittenFile[]>;
44
+ //#endregion
45
+ export { WriteArchiveEntriesInput, WrittenFile, writeArchiveEntries };
package/dist/node.mjs ADDED
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{resolveLinkTarget as t}from"./archive-entry.mjs";import{mapConcurrent as n}from"./bounded-stream.mjs";import{createHash as r}from"node:crypto";import{chmod as i,mkdir as a,symlink as o,writeFile as s}from"node:fs/promises";import{join as c,toNamespacedPath as l}from"node:path";const u=process.platform===`win32`,d=t=>new e(`EXTRACTION_REFUSED`,`Path '${t}' escapes the extraction root.`,{reason:`unsafe-path`,path:t}),f=e=>{let t=e.split(`/`),n=e=>e.length===0||e===`.`||e===`..`||e.includes(`\\`)||e.includes(`\0`)||u&&e.includes(`:`);if(e.length===0||t.some(e=>n(e)))throw d(e);return t},p=(n,r)=>{let i=n;for(let a=0;a<40&&i.symlinkTarget!==void 0;a+=1){let a=t(i.path,i.symlinkTarget),o=a===void 0?void 0:r.get(a);if(o===void 0)throw new e(`EXTRACTION_REFUSED`,`Symlink '${n.path}' -> '${n.symlinkTarget}' does not resolve to a file inside the extraction root.`,{reason:`escaping-link`,path:n.path});i=o}if(i.symlinkTarget!==void 0)throw new e(`EXTRACTION_REFUSED`,`Symlink '${n.path}' forms a loop.`,{reason:`escaping-link`,path:n.path});return i},m=async t=>{let{root:d,entries:m,signal:h,links:g=u?`copy`:`symlink`}=t,_=t.destination===void 0||t.destination===`.`?[]:f(t.destination),v=new Map;for(let e of m)f(e.path),v.set(e.path,e);let y=m.map(e=>{let t=p(e,v);return{entry:e,row:{path:[..._,e.path].join(`/`),byteLength:t.bytes.byteLength,sha256:r(`sha256`).update(t.bytes).digest(`hex`),mode:(t.mode??420)&511,...e.symlinkTarget===void 0?{}:{symlinkTarget:e.symlinkTarget}}}}),b=e=>l(c(d,...e.split(`/`))),x=new Set([_.join(`/`),...y.map(({row:e})=>e.path.split(`/`).slice(0,-1).join(`/`))]);for(let e of[...x].toSorted())h?.throwIfAborted(),await a(b(e),{recursive:!0});return await n(y,32,async({entry:t,row:n})=>{h?.throwIfAborted();let r=b(n.path);try{if(t.symlinkTarget!==void 0&&g===`symlink`){await o(t.symlinkTarget,r);return}await s(r,p(t,v).bytes,{flag:`wx`,mode:n.mode}),u||await i(r,n.mode)}catch(t){throw t.code===`EEXIST`?new e(`EXTRACTION_REFUSED`,`'${n.path}' was already written; two entries name it or the volume merges their case.`,{reason:`duplicate-path`,path:n.path,cause:t}):t}}),y.map(({row:e})=>e)};export{m as writeArchiveEntries};
package/dist/tar.d.mts ADDED
@@ -0,0 +1,19 @@
1
+ import { ArchiveContents, ReadArchiveOptions } from "./archive-entry.mjs";
2
+ //#region src/tar.d.ts
3
+ /**
4
+ * Read an uncompressed ustar/pax tarball (GNU long names included). Directories are implied by file paths.
5
+ *
6
+ * With `links: 'contained'` (the default) a symlink whose target resolves inside the extraction root is returned
7
+ * with `symlinkTarget`; an escaping link, a hardlink, a device or a FIFO refuses the whole archive. With
8
+ * `links: 'skip'` only regular files are returned and every other entry is listed in `skipped`. Modes are returned
9
+ * as recorded; writing applies them.
10
+ * @param bytes - Uncompressed tar bytes, for example from `gunzip`.
11
+ * @param options - Caps, `stripComponents` and the link policy.
12
+ * @returns Root-relative entries plus the skipped entry names.
13
+ * @throws {ArchiveError} `EXTRACTION_REFUSED` for an empty, absolute, backslashed or `..` path, an escaping link, a
14
+ * special file, an exceeded cap or a malformed archive.
15
+ * @public
16
+ */
17
+ declare const readTarEntries: (bytes: Uint8Array<ArrayBuffer>, options: ReadArchiveOptions) => ArchiveContents;
18
+ //#endregion
19
+ export { readTarEntries };
package/dist/tar.mjs ADDED
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{assertEntryCount as t,entrySegments as n,resolveLinkTarget as r}from"./archive-entry.mjs";const i=new TextDecoder,a=t=>new e(`EXTRACTION_REFUSED`,t,{reason:`malformed`}),o=(e,t,n)=>{let r=e.subarray(t,t+n),a=r.indexOf(0);return i.decode(a===-1?r:r.subarray(0,a))},s=(e,t,n)=>{if((e[t]??0)>=128)throw a(`Tar entry uses a base-256 numeric field, which package tarballs never need.`);let r=o(e,t,n).trim();return r.length===0?0:Number.parseInt(r,8)},c=e=>{let t=0;for(let[n,r]of e.entries())t+=n>=148&&n<156?32:r;return t===s(e,148,8)},l=e=>{let t={},n=0;for(;n<e.length;){let r=e.indexOf(32,n),o=Number.parseInt(i.decode(e.subarray(n,r)),10);if(r===-1||!Number.isInteger(o)||o<=0)throw a(`Tar pax header is malformed.`);let s=i.decode(e.subarray(r+1,n+o-1)),c=s.indexOf(`=`),l=s.slice(0,c);(l===`path`||l===`linkpath`)&&(t[l]=s.slice(c+1)),n+=o}return t},u=e=>{let t=o(e,257,6)===`ustar`?o(e,345,155):``,n=o(e,0,100);return t.length>0?`${t}/${n}`:n},d=new Set([`0`,`\0`,`7`]),f=(i,f)=>{let{maximumEntries:p,maximumUnpackedBytes:m,stripComponents:h=0,links:g=`contained`}=f,_=[],v=[],y=0,b,x,S=0;for(;S+512<=i.length;){let f=i.subarray(S,S+512);if(f.every(e=>e===0))break;if(!c(f))throw a(`Tar header at byte ${S} has an invalid checksum.`);t(_.length+v.length+1,p,`Tarball`);let C=s(f,124,12),w=S+512;if(w+C>i.length)throw a(`Tarball is truncated.`);let T=i.subarray(w,w+C);S=w+Math.ceil(C/512)*512;let E=String.fromCodePoint(f[156]??0);if(E===`x`){let e=l(T);b=e.path??b,x=e.linkpath??x;continue}if(E===`L`){b=o(T,0,T.length);continue}if(E===`K`){x=o(T,0,T.length);continue}if(E===`g`)continue;let D=b??u(f),O=x??o(f,157,100);if(b=void 0,x=void 0,E===`5`)continue;let k=n(D).slice(h),A=k.length===0?void 0:k.join(`/`);if(A===void 0){v.push(D);continue}if(d.has(E)){if(y+=C,y>m)throw new e(`EXTRACTION_REFUSED`,`Tarball unpacks to more than ${m} bytes.`,{reason:`limit`});_.push({path:A,bytes:new Uint8Array(T),mode:s(f,100,8)});continue}if(g===`skip`){v.push(A);continue}if(E!==`2`)throw new e(`EXTRACTION_REFUSED`,`Tar entry '${D}' is a hardlink or special file.`,{reason:`special-entry`,path:D});if(r(A,O)===void 0)throw new e(`EXTRACTION_REFUSED`,`Tar symlink '${D}' -> '${O}' escapes the extraction root.`,{reason:`escaping-link`,path:D});_.push({path:A,bytes:new Uint8Array,mode:s(f,100,8),symlinkTarget:O})}return{entries:_,skipped:v}};export{f as readTarEntries};
@@ -0,0 +1,31 @@
1
+ import { ArchiveLimits } from "./bounded-stream.mjs";
2
+ import { ArchiveContents, ArchiveEntry } from "./archive-entry.mjs";
3
+ //#region src/wheel.d.ts
4
+ /**
5
+ * A wheel whose every member matched its `RECORD` row.
6
+ * @public
7
+ */
8
+ type WheelContents = ArchiveContents & {
9
+ /** Distribution name as the `.dist-info` directory spells it. */
10
+ readonly distribution: string;
11
+ readonly version: string;
12
+ /** Files of the `data` scheme (`<name>.data/data/**`), relative to the interpreter prefix rather than site-packages. */
13
+ readonly data: readonly ArchiveEntry[];
14
+ };
15
+ /**
16
+ * Read a wheel as an installer would, without running anything: every member is checked against `RECORD`
17
+ * (RFC 4180 rows, unpadded base64url sha256/384/512 hashes, sizes); `RECORD` itself and `RECORD.jws`/`RECORD.p7s`
18
+ * are listed unhashed. One wrong, missing or extra row refuses the whole wheel. `<name>.data/data/**` is returned
19
+ * in `data`, relative to the interpreter prefix; every other `.data/` scheme (`scripts`, `headers`, `purelib`,
20
+ * `platlib`) is refused.
21
+ * @param bytes - The wheel.
22
+ * @param limits - Entry and unpacked-byte caps.
23
+ * @returns Site-packages-relative entries, the `data` scheme files and the distribution name and version.
24
+ * @throws {ArchiveError} `INTEGRITY_MISMATCH` (`record`) when a member disagrees with `RECORD`;
25
+ * `UNSUPPORTED_FEATURE` (`data-scheme`) for a `.data/` scheme other than `data`; `EXTRACTION_REFUSED` for an
26
+ * unsafe path, an exceeded cap or a malformed wheel.
27
+ * @public
28
+ */
29
+ declare const readWheel: (bytes: Uint8Array<ArrayBuffer>, limits: ArchiveLimits) => Promise<WheelContents>;
30
+ //#endregion
31
+ export { WheelContents, readWheel };
package/dist/wheel.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{entrySegments as t}from"./archive-entry.mjs";import{mapConcurrent as n}from"./bounded-stream.mjs";import{assertDirectoryWithinLimits as r,assertSupportedMember as i,readZipDirectory as a,readZipMember as o}from"./zip.mjs";const s=new TextDecoder,c={sha256:`SHA-256`,sha384:`SHA-384`,sha512:`SHA-512`},l=new Set([`RECORD`,`RECORD.jws`,`RECORD.p7s`]),u=(t,n)=>new e(`INTEGRITY_MISMATCH`,t,{reason:`record`,path:n}),d=(t,n)=>new e(`EXTRACTION_REFUSED`,t,{reason:`malformed`,path:n}),f=e=>{let t=[],n=[],r=``,i=!1;for(let a=0;a<e.length;a+=1){let o=e[a];i?o===`"`&&e[a+1]===`"`?(r+=`"`,a+=1):o===`"`?i=!1:r+=o:o===`"`&&r===``?i=!0:o===`,`?(n.push(r),r=``):o===`
2
+ `||o===`\r`?(o===`\r`&&e[a+1]===`
3
+ `&&(a+=1),n.push(r),t.push(n),n=[],r=``):r+=o}return(r!==``||n.length>0)&&(n.push(r),t.push(n)),t.filter(e=>e.length!==1||e[0]!==``)},p=e=>btoa(String.fromCodePoint(...new Uint8Array(e))).replaceAll(`+`,`-`).replaceAll(`/`,`_`).replace(/=+$/u,``),m=(e,t)=>{let n=new Map;for(let r of f(e)){let[e,i=``,a=``]=r;if(e===void 0||e===``||r.length>3)throw d(`${t} has a malformed row: ${JSON.stringify(r)}.`,t);if(n.has(e))throw d(`${t} lists '${e}' twice.`,t);n.set(e,{hash:i,size:a})}return n},h=async e=>{let{name:t,bytes:n,row:r,unhashed:i}=e;if(i){if(r!==void 0&&r.hash!==``)throw u(`RECORD hashes '${t}', which must be listed without a hash.`,t);return}if(r===void 0||r.hash===``)throw u(`Wheel member '${t}' has no hashed RECORD row.`,t);let a=r.hash.indexOf(`=`),o=r.hash.slice(0,a),s=r.hash.slice(a+1);if(!Object.hasOwn(c,o)||!/^[\w-]+$/u.test(s))throw u(`RECORD row for '${t}' has '${r.hash}', not a sha256/384/512 base64url hash without padding.`,t);if(p(await crypto.subtle.digest(c[o],n))!==s)throw u(`Wheel member '${t}' does not match its RECORD hash.`,t);if(r.size!==``&&Number(r.size)!==n.byteLength)throw u(`Wheel member '${t}' does not match its RECORD size.`,t)},g=e=>{let t=[...new Set(e.map(e=>e.name.split(`/`)[0]??``).filter(e=>e.endsWith(`.dist-info`)))],[n]=t,r=n?.slice(0,-10)??``,i=r.indexOf(`-`);if(t.length!==1||i<=0)throw d(`Wheel must contain exactly one '<name>-<version>.dist-info' directory; found ${t.length}.`);return{stem:r,distribution:r.slice(0,i),version:r.slice(i+1)}},_=async(c,f)=>{let p=a(c,f.maximumEntries);r(p,f.maximumUnpackedBytes);for(let e of p)t(e.name),i(e);let _=p.filter(e=>!e.directory),{stem:v,distribution:y,version:b}=g(_),x=`${v}.dist-info/RECORD`,S=`${v}.data/`;for(let t of _){let n=t.name.startsWith(S)?t.name.slice(S.length).split(`/`)[0]:void 0;if(n!==void 0&&n!==`data`)throw new e(`UNSUPPORTED_FEATURE`,`Wheel member '${t.name}' uses the '${n}' .data scheme; only 'data' is installed.`,{reason:`data-scheme`,path:t.name})}let C=_.find(e=>e.name===x);if(C===void 0)throw d(`Wheel has no ${x}.`,x);let w=m(s.decode(await o(c,C)),x),T=e=>{let t=e.lastIndexOf(`/`);return e.slice(0,t)===`${v}.dist-info`&&l.has(e.slice(t+1))},E=await n(_,8,async e=>{let t=T(e.name),n=await o(c,e,t);return await h({name:e.name,bytes:n,row:w.get(e.name),unhashed:t}),{file:e,contents:n}}),D=[],O=[];for(let{file:e,contents:t}of E){w.delete(e.name);let n=e.mode===void 0?{}:{mode:e.mode};if(e.name.startsWith(`${S}data/`)){let r=e.name.slice(`${S}data/`.length);if(r.length===0)throw d(`Wheel member '${e.name}' has no path inside the data scheme.`,e.name);O.push({path:r,bytes:t,...n})}else D.push({path:e.name,bytes:t,...n})}let[k]=w.keys();if(k!==void 0)throw u(`RECORD lists '${k}', which the wheel does not contain.`,k);return{distribution:y,version:b,entries:D,data:O,skipped:[]}};export{f as parseCsv,_ as readWheel};
package/dist/zip.d.mts ADDED
@@ -0,0 +1,16 @@
1
+ import { ArchiveContents, ReadArchiveOptions } from "./archive-entry.mjs";
2
+ //#region src/zip.d.ts
3
+ /**
4
+ * Read a zip archive: central directory (ZIP64 included), stored and deflated members through the platform
5
+ * `DecompressionStream('deflate-raw')`, every member's size and CRC-32 checked. Directory entries are implied by
6
+ * file paths. Encrypted members, symlinks and other compression methods are refused.
7
+ * @param bytes - The archive.
8
+ * @param options - Caps and `stripComponents`; zip archives carry no links to keep, so `links` has no effect.
9
+ * @returns Root-relative entries, with POSIX modes when the archive was made on Unix.
10
+ * @throws {ArchiveError} `EXTRACTION_REFUSED` for an unsafe or duplicate path, an exceeded cap or a corrupt
11
+ * member; `UNSUPPORTED_FEATURE` for encryption, a symlink or another compression method.
12
+ * @public
13
+ */
14
+ declare const readZipEntries: (bytes: Uint8Array<ArrayBuffer>, options: ReadArchiveOptions) => Promise<ArchiveContents>;
15
+ //#endregion
16
+ export { readZipEntries };
package/dist/zip.mjs ADDED
@@ -0,0 +1 @@
1
+ import{ArchiveError as e}from"./archive-error.mjs";import{assertEntryCount as t,entrySegments as n}from"./archive-entry.mjs";import{decompress as r,mapConcurrent as i}from"./bounded-stream.mjs";const a=new TextDecoder,o=(t,n,r)=>new e(`EXTRACTION_REFUSED`,t,{reason:`malformed`,path:n,cause:r}),s=Uint32Array.from({length:256},(e,t)=>{let n=t;for(let e=0;e<8;e+=1)n=n&1?3988292384^n>>>1:n>>>1;return n>>>0}),c=e=>{let t=4294967295;for(let n of e)t=(s[(t^n)&255]??0)^t>>>8;return(t^4294967295)>>>0},l=e=>new DataView(e.buffer,e.byteOffset,e.byteLength),u=(e,t)=>{let n=Math.max(0,e.byteLength-22-65535);for(let r=e.byteLength-22;r>=n;--r)if(t.getUint32(r,!0)===101010256){if(r>=20&&t.getUint32(r-20,!0)===117853008){let n=Number(t.getBigUint64(r-12,!0));if(n+56>e.byteLength||t.getUint32(n,!0)!==101075792)throw o(`Zip64 end of central directory is missing.`);return{entries:Number(t.getBigUint64(n+32,!0)),start:Number(t.getBigUint64(n+48,!0))}}return{entries:t.getUint16(r+10,!0),start:t.getUint32(r+16,!0)}}throw o(`Zip end of central directory not found.`)},d=(e,n)=>{let r=l(e),i=u(e,r);t(i.entries,n,`Zip archive`);let s=[],c=i.start;for(let t=0;t<i.entries;t+=1){if(c+46>e.byteLength||r.getUint32(c,!0)!==33639248)throw o(`Zip central directory record ${t} is corrupt.`);let n=r.getUint16(c+28,!0),i=r.getUint16(c+30,!0),l=r.getUint16(c+32,!0),u=a.decode(e.subarray(c+46,c+46+n)),d=r.getUint16(c+4,!0)>>8,f=r.getUint32(c+38,!0)>>>16,p={size:r.getUint32(c+24,!0),compressedSize:r.getUint32(c+20,!0),localOffset:r.getUint32(c+42,!0)},m=c+46+n,h=m+i;for(;m+4<=h;){if(r.getUint16(m,!0)===1){let e=m+4;for(let t of[`size`,`compressedSize`,`localOffset`])p[t]===4294967295&&(p[t]=Number(r.getBigUint64(e,!0)),e+=8)}m+=4+r.getUint16(m+2,!0)}s.push({name:u,flags:r.getUint16(c+8,!0),method:r.getUint16(c+10,!0),crc:r.getUint32(c+16,!0),...p,mode:d===3&&f!==0?f&4095:void 0,symlink:d===3&&(f&61440)==40960,directory:u.endsWith(`/`)}),c=h+l}return s},f=async(e,t,n=!0)=>{let i=l(e),a=t.localOffset;if(a+30>e.byteLength||i.getUint32(a,!0)!==67324752)throw o(`Zip member '${t.name}' has no local header.`,t.name);let s=a+30+i.getUint16(a+26,!0)+i.getUint16(a+28,!0);if(s+t.compressedSize>e.byteLength)throw o(`Zip member '${t.name}' is truncated.`,t.name);let u=e.subarray(s,s+t.compressedSize),d=t.method===0?new Uint8Array(u):await r(u,`deflate-raw`,t.size).catch(e=>{throw o(`Zip member '${t.name}' does not inflate to its declared size.`,t.name,e)});if(d.byteLength!==t.size||n&&c(d)!==t.crc)throw o(`Zip member '${t.name}' does not match its recorded size and CRC-32.`,t.name);return d},p=t=>{if(t.flags&1)throw new e(`UNSUPPORTED_FEATURE`,`Zip member '${t.name}' is encrypted.`,{reason:`encryption`,path:t.name});if(t.symlink)throw new e(`UNSUPPORTED_FEATURE`,`Zip member '${t.name}' is a symlink.`,{reason:`zip-symlink`,path:t.name});if(!t.directory&&t.method!==0&&t.method!==8)throw new e(`UNSUPPORTED_FEATURE`,`Zip member '${t.name}' uses compression method ${t.method}; only stored and deflate are read.`,{reason:`compression-method`,path:t.name})},m=(t,n)=>{let r=new Set,i=0;for(let n of t){if(r.has(n.name))throw new e(`EXTRACTION_REFUSED`,`Zip member '${n.name}' appears twice.`,{reason:`duplicate-path`,path:n.name});r.add(n.name),i+=n.size}if(i>n)throw new e(`EXTRACTION_REFUSED`,`Zip archive unpacks to more than ${n} bytes.`,{reason:`limit`})},h=async(e,t)=>{let{stripComponents:r=0}=t,a=d(e,t.maximumEntries);m(a,t.maximumUnpackedBytes);let o=[],s=[];for(let e of a){let t=n(e.name).slice(r);if(p(e),!e.directory){if(t.length===0){o.push(e.name);continue}s.push({member:e,path:t.join(`/`)})}}return{entries:await i(s,8,async({member:t,path:n})=>({path:n,bytes:await f(e,t),...t.mode===void 0?{}:{mode:t.mode}})),skipped:o}};export{m as assertDirectoryWithinLimits,p as assertSupportedMember,c as crc32,d as readZipDirectory,h as readZipEntries,f as readZipMember};
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@taucad/archive-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-beta.2",
4
+ "description": "Verified archive acquisition and tar, zip and wheel readers for Tau payloads",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "private": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/taucad/tau.git",
11
+ "directory": "packages/core/archive"
12
+ },
13
+ "homepage": "https://github.com/taucad/tau/tree/main/packages/core/archive#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/taucad/tau/issues"
16
+ },
17
+ "main": "./dist/index.mjs",
18
+ "types": "./dist/index.d.mts",
19
+ "files": [
20
+ "dist",
21
+ "LICENSE",
22
+ "README.md",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "keywords": [
26
+ "tar",
27
+ "zip",
28
+ "wheel",
29
+ "integrity",
30
+ "sri",
31
+ "core"
32
+ ],
33
+ "author": "Richard Fontein",
34
+ "license": "Apache-2.0",
35
+ "engines": {
36
+ "node": ">=24.0.0"
37
+ },
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.mts",
41
+ "import": "./dist/index.mjs",
42
+ "default": "./dist/index.mjs"
43
+ },
44
+ "./node": {
45
+ "types": "./dist/node.d.mts",
46
+ "import": "./dist/node.mjs",
47
+ "default": "./dist/node.mjs"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "imports": {},
55
+ "taucad": {
56
+ "hostTarget": "browser"
57
+ }
6
58
  }