zyrk 0.3.8 → 0.4.8

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.
Files changed (218) hide show
  1. package/README.md +143 -34
  2. package/SECURITY.md +42 -0
  3. package/VERSION +1 -1
  4. package/bin/zyrk.js +9 -1
  5. package/dist/cli/main.d.ts +40 -0
  6. package/dist/cli/main.d.ts.map +1 -0
  7. package/dist/cli/main.js +605 -0
  8. package/dist/cli/npm-delegate.d.ts +38 -0
  9. package/dist/cli/npm-delegate.d.ts.map +1 -0
  10. package/{src/cli/npm-delegate.ts → dist/cli/npm-delegate.js} +21 -55
  11. package/dist/cli/parse.d.ts +40 -0
  12. package/dist/cli/parse.d.ts.map +1 -0
  13. package/dist/cli/parse.js +78 -0
  14. package/dist/commands/dependencies.d.ts +13 -0
  15. package/dist/commands/dependencies.d.ts.map +1 -0
  16. package/dist/commands/dependencies.js +119 -0
  17. package/dist/commands/deploy.d.ts +15 -0
  18. package/dist/commands/deploy.d.ts.map +1 -0
  19. package/dist/commands/deploy.js +761 -0
  20. package/dist/commands/maintenance.d.ts +28 -0
  21. package/dist/commands/maintenance.d.ts.map +1 -0
  22. package/dist/commands/maintenance.js +225 -0
  23. package/dist/commands/patch.d.ts +39 -0
  24. package/dist/commands/patch.d.ts.map +1 -0
  25. package/dist/commands/patch.js +353 -0
  26. package/dist/commands/project.d.ts +17 -0
  27. package/dist/commands/project.d.ts.map +1 -0
  28. package/dist/commands/project.js +519 -0
  29. package/dist/commands/store.d.ts +10 -0
  30. package/dist/commands/store.d.ts.map +1 -0
  31. package/dist/commands/store.js +610 -0
  32. package/dist/config/paths.d.ts +37 -0
  33. package/dist/config/paths.d.ts.map +1 -0
  34. package/dist/config/paths.js +66 -0
  35. package/dist/dlx/create.d.ts +23 -0
  36. package/dist/dlx/create.d.ts.map +1 -0
  37. package/dist/dlx/create.js +59 -0
  38. package/dist/dlx/run.d.ts +53 -0
  39. package/dist/dlx/run.d.ts.map +1 -0
  40. package/dist/dlx/run.js +395 -0
  41. package/dist/errors.d.ts +68 -0
  42. package/dist/errors.d.ts.map +1 -0
  43. package/dist/errors.js +116 -0
  44. package/dist/extract/tarball.d.ts +86 -0
  45. package/dist/extract/tarball.d.ts.map +1 -0
  46. package/dist/extract/tarball.js +727 -0
  47. package/dist/fetch/all.d.ts +54 -0
  48. package/dist/fetch/all.d.ts.map +1 -0
  49. package/dist/fetch/all.js +168 -0
  50. package/dist/fetch/package.d.ts +65 -0
  51. package/dist/fetch/package.d.ts.map +1 -0
  52. package/dist/fetch/package.js +197 -0
  53. package/dist/fetch/registry.d.ts +125 -0
  54. package/dist/fetch/registry.d.ts.map +1 -0
  55. package/dist/fetch/registry.js +674 -0
  56. package/dist/install/import-npm.d.ts +4 -0
  57. package/dist/install/import-npm.d.ts.map +1 -0
  58. package/dist/install/import-npm.js +156 -0
  59. package/dist/install/lock.d.ts +10 -0
  60. package/dist/install/lock.d.ts.map +1 -0
  61. package/dist/install/lock.js +239 -0
  62. package/dist/install/metadata.d.ts +25 -0
  63. package/dist/install/metadata.d.ts.map +1 -0
  64. package/dist/install/metadata.js +92 -0
  65. package/dist/install/private.d.ts +46 -0
  66. package/dist/install/private.d.ts.map +1 -0
  67. package/dist/install/private.js +304 -0
  68. package/dist/install/registry.d.ts +8 -0
  69. package/dist/install/registry.d.ts.map +1 -0
  70. package/dist/install/registry.js +98 -0
  71. package/dist/install/repair.d.ts +22 -0
  72. package/dist/install/repair.d.ts.map +1 -0
  73. package/dist/install/repair.js +115 -0
  74. package/dist/install/run.d.ts +45 -0
  75. package/dist/install/run.d.ts.map +1 -0
  76. package/dist/install/run.js +716 -0
  77. package/dist/install/source-policy.d.ts +11 -0
  78. package/dist/install/source-policy.d.ts.map +1 -0
  79. package/dist/install/source-policy.js +8 -0
  80. package/dist/install/workspace-graph.d.ts +62 -0
  81. package/dist/install/workspace-graph.d.ts.map +1 -0
  82. package/dist/install/workspace-graph.js +365 -0
  83. package/dist/install/workspace-install.d.ts +3 -0
  84. package/dist/install/workspace-install.d.ts.map +1 -0
  85. package/dist/install/workspace-install.js +527 -0
  86. package/dist/install/workspace-lock.d.ts +16 -0
  87. package/dist/install/workspace-lock.d.ts.map +1 -0
  88. package/dist/install/workspace-lock.js +428 -0
  89. package/dist/install/workspace-mutation.d.ts +10 -0
  90. package/dist/install/workspace-mutation.d.ts.map +1 -0
  91. package/dist/install/workspace-mutation.js +154 -0
  92. package/dist/install/workspace-transaction.d.ts +26 -0
  93. package/dist/install/workspace-transaction.d.ts.map +1 -0
  94. package/dist/install/workspace-transaction.js +191 -0
  95. package/dist/install/workspaces.d.ts +66 -0
  96. package/dist/install/workspaces.d.ts.map +1 -0
  97. package/dist/install/workspaces.js +343 -0
  98. package/dist/lockfile/fingerprints.d.ts +45 -0
  99. package/dist/lockfile/fingerprints.d.ts.map +1 -0
  100. package/dist/lockfile/fingerprints.js +65 -0
  101. package/dist/lockfile/read.d.ts +27 -0
  102. package/dist/lockfile/read.d.ts.map +1 -0
  103. package/dist/lockfile/read.js +136 -0
  104. package/dist/lockfile/types.d.ts +77 -0
  105. package/dist/lockfile/types.d.ts.map +1 -0
  106. package/dist/lockfile/types.js +18 -0
  107. package/dist/lockfile/write.d.ts +36 -0
  108. package/dist/lockfile/write.d.ts.map +1 -0
  109. package/dist/lockfile/write.js +211 -0
  110. package/dist/log.d.ts +24 -0
  111. package/dist/log.d.ts.map +1 -0
  112. package/dist/log.js +126 -0
  113. package/dist/project/projection.d.ts +50 -0
  114. package/dist/project/projection.d.ts.map +1 -0
  115. package/dist/project/projection.js +292 -0
  116. package/dist/resolve/resolver.d.ts +99 -0
  117. package/dist/resolve/resolver.d.ts.map +1 -0
  118. package/dist/resolve/resolver.js +669 -0
  119. package/dist/resolve/spec.d.ts +26 -0
  120. package/dist/resolve/spec.d.ts.map +1 -0
  121. package/dist/resolve/spec.js +87 -0
  122. package/dist/store/db.d.ts +49 -0
  123. package/dist/store/db.d.ts.map +1 -0
  124. package/{src/store/db.ts → dist/store/db.js} +106 -206
  125. package/dist/store/graph.d.ts +60 -0
  126. package/dist/store/graph.d.ts.map +1 -0
  127. package/dist/store/graph.js +115 -0
  128. package/dist/store/layout.d.ts +71 -0
  129. package/dist/store/layout.d.ts.map +1 -0
  130. package/dist/store/layout.js +282 -0
  131. package/dist/store/manifests.d.ts +78 -0
  132. package/dist/store/manifests.d.ts.map +1 -0
  133. package/dist/store/manifests.js +238 -0
  134. package/dist/store/objects.d.ts +59 -0
  135. package/dist/store/objects.d.ts.map +1 -0
  136. package/dist/store/objects.js +194 -0
  137. package/dist/store/realize.d.ts +44 -0
  138. package/dist/store/realize.d.ts.map +1 -0
  139. package/dist/store/realize.js +302 -0
  140. package/dist/store/source.d.ts +42 -0
  141. package/dist/store/source.d.ts.map +1 -0
  142. package/dist/store/source.js +169 -0
  143. package/dist/store/txn.d.ts +37 -0
  144. package/dist/store/txn.d.ts.map +1 -0
  145. package/dist/store/txn.js +97 -0
  146. package/dist/store/verify.d.ts +41 -0
  147. package/dist/store/verify.d.ts.map +1 -0
  148. package/dist/store/verify.js +405 -0
  149. package/dist/util/fsx.d.ts +54 -0
  150. package/dist/util/fsx.d.ts.map +1 -0
  151. package/dist/util/fsx.js +259 -0
  152. package/dist/util/hash.d.ts +48 -0
  153. package/dist/util/hash.d.ts.map +1 -0
  154. package/dist/util/hash.js +80 -0
  155. package/dist/util/ids.d.ts +19 -0
  156. package/dist/util/ids.d.ts.map +1 -0
  157. package/dist/util/ids.js +38 -0
  158. package/dist/util/integrity.d.ts +107 -0
  159. package/dist/util/integrity.d.ts.map +1 -0
  160. package/dist/util/integrity.js +227 -0
  161. package/dist/util/json.d.ts +14 -0
  162. package/dist/util/json.d.ts.map +1 -0
  163. package/dist/util/json.js +78 -0
  164. package/dist/util/locks.d.ts +40 -0
  165. package/dist/util/locks.d.ts.map +1 -0
  166. package/dist/util/locks.js +219 -0
  167. package/dist/util/proc.d.ts +53 -0
  168. package/dist/util/proc.d.ts.map +1 -0
  169. package/dist/util/proc.js +189 -0
  170. package/dist/version.d.ts +27 -0
  171. package/dist/version.d.ts.map +1 -0
  172. package/{src/version.ts → dist/version.js} +0 -8
  173. package/package.json +9 -4
  174. package/src/cli/main.ts +0 -602
  175. package/src/cli/parse.ts +0 -106
  176. package/src/commands/dependencies.ts +0 -154
  177. package/src/commands/deploy.ts +0 -777
  178. package/src/commands/maintenance.ts +0 -260
  179. package/src/commands/patch.ts +0 -408
  180. package/src/commands/project.ts +0 -598
  181. package/src/commands/store.ts +0 -689
  182. package/src/config/paths.ts +0 -119
  183. package/src/dlx/create.ts +0 -69
  184. package/src/dlx/run.ts +0 -389
  185. package/src/errors.ts +0 -142
  186. package/src/extract/tarball.ts +0 -868
  187. package/src/fetch/all.ts +0 -206
  188. package/src/fetch/package.ts +0 -181
  189. package/src/fetch/registry.ts +0 -615
  190. package/src/install/import-npm.ts +0 -177
  191. package/src/install/lock.ts +0 -176
  192. package/src/install/metadata.ts +0 -122
  193. package/src/install/registry.ts +0 -88
  194. package/src/install/run.ts +0 -760
  195. package/src/lockfile/fingerprints.ts +0 -74
  196. package/src/lockfile/read.ts +0 -198
  197. package/src/lockfile/types.ts +0 -75
  198. package/src/lockfile/write.ts +0 -257
  199. package/src/log.ts +0 -100
  200. package/src/project/projection.ts +0 -390
  201. package/src/resolve/resolver.ts +0 -757
  202. package/src/resolve/spec.ts +0 -131
  203. package/src/store/graph.ts +0 -175
  204. package/src/store/layout.ts +0 -431
  205. package/src/store/manifests.ts +0 -347
  206. package/src/store/objects.ts +0 -229
  207. package/src/store/realize.ts +0 -322
  208. package/src/store/source.ts +0 -249
  209. package/src/store/txn.ts +0 -118
  210. package/src/store/verify.ts +0 -433
  211. package/src/types/vendor.d.ts +0 -120
  212. package/src/util/fsx.ts +0 -289
  213. package/src/util/hash.ts +0 -117
  214. package/src/util/ids.ts +0 -42
  215. package/src/util/integrity.ts +0 -269
  216. package/src/util/json.ts +0 -89
  217. package/src/util/locks.ts +0 -266
  218. package/src/util/proc.ts +0 -108
@@ -1,868 +0,0 @@
1
- import { createReadStream } from "node:fs";
2
- import {
3
- chmod,
4
- link as fsLink,
5
- lstat as fsLstat,
6
- mkdir as fsMkdir,
7
- open,
8
- rm,
9
- symlink as fsSymlink,
10
- } from "node:fs/promises";
11
- import * as path from "node:path";
12
- import { Parser, type ReadEntry } from "tar";
13
- import { ZyrkError } from "../errors.ts";
14
- import { Digest, modeClassOf, type ModeClass } from "../util/hash.ts";
15
- import { ensureDirWithMode, isErrno } from "../util/fsx.ts";
16
-
17
- /**
18
- * Safe archive extraction (SPEC 13.4).
19
- *
20
- * Two passes over the archive: the first validates every entry (paths,
21
- * collisions, limits, supported types) and decides how a single common root
22
- * directory, if any, is stripped; the second writes entries under the
23
- * transaction's staging directory. The validation pass writes nothing.
24
- *
25
- * Rules enforced here:
26
- * - absolute paths, drive letters, backslashes, NUL bytes, and `..`
27
- * traversal are rejected;
28
- * - case-folding collisions are rejected; Windows-reserved names and
29
- * invalid characters are detected and reported (deploy targets re-check);
30
- * - only regular files, directories, symlinks, and validated hard links are
31
- * accepted;
32
- * - symlink and hard-link targets must stay inside the package root;
33
- * - setuid/setgid/sticky bits and archive ownership are never applied;
34
- * - files are created exclusively (`wx`) and never written through a
35
- * previously created symlink;
36
- * - entry count, total bytes, and path lengths are bounded.
37
- */
38
-
39
- export type ExtractionLimits = {
40
- maxEntries: number;
41
- maxTotalBytes: number;
42
- maxPathLength: number;
43
- maxComponentLength: number;
44
- maxSymlinkTargetLength: number;
45
- };
46
-
47
- export const DEFAULT_EXTRACTION_LIMITS: ExtractionLimits = {
48
- maxEntries: 200_000,
49
- maxTotalBytes: 4 * 1024 * 1024 * 1024,
50
- maxPathLength: 4096,
51
- maxComponentLength: 255,
52
- maxSymlinkTargetLength: 4096,
53
- };
54
-
55
- export type ExtractedFileEntry = {
56
- path: string;
57
- type: "file";
58
- mode: ModeClass;
59
- size: number;
60
- innerDigest: string;
61
- /** Absolute path inside the staging tree, for the commit step. */
62
- stagingPath: string;
63
- };
64
-
65
- export type ExtractedDirEntry = { path: string; type: "dir" };
66
-
67
- export type ExtractedSymlinkEntry = {
68
- path: string;
69
- type: "symlink";
70
- target: string;
71
- };
72
-
73
- export type ExtractedEntry =
74
- ExtractedFileEntry | ExtractedDirEntry | ExtractedSymlinkEntry;
75
-
76
- export type ExtractionResult = {
77
- entries: ExtractedEntry[];
78
- totals: {
79
- files: number;
80
- dirs: number;
81
- symlinks: number;
82
- logicalBytes: number;
83
- };
84
- /** Deterministic, sorted outcome codes; stored in the source manifest. */
85
- outcomes: string[];
86
- /** The stripped common root directory, if one was found. */
87
- commonRoot: string | null;
88
- /** Non-fatal observations for CLI warnings. */
89
- observations: string[];
90
- };
91
-
92
- type EntryPlan = {
93
- index: number;
94
- rawPath: string;
95
- effectivePath: string;
96
- type: "file" | "dir" | "symlink" | "hardlink";
97
- size: number;
98
- mode: number;
99
- linkpath: string | null;
100
- };
101
-
102
- const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
103
- const WINDOWS_INVALID_CHARS = /[<>:"|?*\u0000-\u001f\u007f]/;
104
-
105
- export async function extractTarball(options: {
106
- tarballPath: string;
107
- destDir: string;
108
- limits?: Partial<ExtractionLimits>;
109
- }): Promise<ExtractionResult> {
110
- const limits: ExtractionLimits = {
111
- ...DEFAULT_EXTRACTION_LIMITS,
112
- ...options.limits,
113
- };
114
- await ensureDirWithMode(options.destDir, 0o700);
115
- const destRoot = path.resolve(options.destDir);
116
-
117
- // ---- Pass 1: validate and plan (writes nothing) ----
118
- const plans: EntryPlan[] = [];
119
- const observations: string[] = [];
120
- let observedReserved = 0;
121
- let observedWindowsInvalid = 0;
122
-
123
- await runParser(options.tarballPath, async (entry, index) => {
124
- if (plans.length >= limits.maxEntries) {
125
- throw unsafe(`archive has more than ${limits.maxEntries} entries`, {
126
- maxEntries: limits.maxEntries,
127
- });
128
- }
129
- const size = entry.size ?? 0;
130
- if (size > limits.maxTotalBytes) {
131
- throw unsafe(
132
- `archive entry declares ${size} bytes, beyond the limit of ${limits.maxTotalBytes}`,
133
- {
134
- entryPath: entry.path,
135
- },
136
- );
137
- }
138
- const check = normalizeArchivePath(entry.path ?? "", limits);
139
- if (check.reserved) observedReserved += 1;
140
- if (check.invalidWindows) observedWindowsInvalid += 1;
141
-
142
- plans.push({
143
- index,
144
- rawPath: check.normalized,
145
- effectivePath: check.normalized,
146
- type: planType(entry),
147
- size,
148
- mode: typeof entry.mode === "number" ? entry.mode : 0o644,
149
- linkpath:
150
- typeof entry.linkpath === "string" && entry.linkpath !== ""
151
- ? entry.linkpath
152
- : null,
153
- });
154
- entry.resume();
155
- });
156
-
157
- assertNoDuplicateRawPaths(plans);
158
-
159
- // A single wrapping root directory (npm's conventional `package/`) is
160
- // stripped so manifests and realizations are stable regardless of the
161
- // publisher's chosen wrapper name.
162
- const commonRoot = findCommonRoot(plans);
163
- const finalPlans: EntryPlan[] = [];
164
- for (const plan of plans) {
165
- plan.effectivePath =
166
- commonRoot === null
167
- ? plan.rawPath
168
- : stripPrefix(plan.rawPath, commonRoot);
169
- if (plan.effectivePath !== "") finalPlans.push(plan);
170
- }
171
-
172
- // Validate effective paths, duplicates, and byte limits.
173
- const effectiveByPath = new Map<string, EntryPlan>();
174
- const foldedSeen = new Map<string, string>();
175
- let totalBytes = 0;
176
- for (const plan of finalPlans) {
177
- const check = normalizeArchivePath(plan.effectivePath, limits);
178
- if (check.normalized !== plan.effectivePath) {
179
- throw unsafe(
180
- `entry path changes meaning when normalized: ${plan.rawPath}`,
181
- { entryPath: plan.rawPath },
182
- );
183
- }
184
- if (effectiveByPath.has(plan.effectivePath)) {
185
- throw unsafe(
186
- `archive contains ${JSON.stringify(plan.effectivePath)} more than once`,
187
- {
188
- entryPath: plan.effectivePath,
189
- },
190
- );
191
- }
192
- effectiveByPath.set(plan.effectivePath, plan);
193
-
194
- const folded = casefold(plan.effectivePath);
195
- const clash = foldedSeen.get(folded);
196
- if (clash !== undefined) {
197
- throw unsafe(
198
- `case-folding collision between ${JSON.stringify(clash)} and ${JSON.stringify(plan.effectivePath)}`,
199
- {
200
- pathA: clash,
201
- pathB: plan.effectivePath,
202
- },
203
- );
204
- }
205
- foldedSeen.set(folded, plan.effectivePath);
206
-
207
- if (plan.type === "file" || plan.type === "hardlink") {
208
- totalBytes += plan.size;
209
- if (totalBytes > limits.maxTotalBytes) {
210
- throw unsafe(
211
- `archive expands to more than ${limits.maxTotalBytes} bytes`,
212
- {
213
- maxTotalBytes: limits.maxTotalBytes,
214
- },
215
- );
216
- }
217
- }
218
- }
219
-
220
- // Resolve hard-link targets against the extracted tree.
221
- for (const plan of finalPlans) {
222
- if (plan.type !== "hardlink") continue;
223
- if (plan.linkpath === null) {
224
- throw unsafe(`hard link without a target: ${plan.effectivePath}`, {
225
- entryPath: plan.effectivePath,
226
- });
227
- }
228
- const targetCheck = normalizeArchivePath(plan.linkpath, limits);
229
- const target =
230
- commonRoot === null
231
- ? targetCheck.normalized
232
- : stripPrefix(targetCheck.normalized, commonRoot);
233
- const targetPlan = target === "" ? undefined : effectiveByPath.get(target);
234
- if (targetPlan === undefined || targetPlan.type !== "file") {
235
- throw unsafe(
236
- `hard link target is not an extracted regular file: ${plan.effectivePath} -> ${plan.linkpath}`,
237
- {
238
- entryPath: plan.effectivePath,
239
- target: plan.linkpath,
240
- },
241
- );
242
- }
243
- plan.linkpath = target;
244
- }
245
-
246
- // Validate symlink targets lexically before writing anything.
247
- for (const plan of finalPlans) {
248
- if (plan.type !== "symlink") continue;
249
- const target = plan.linkpath;
250
- if (target === null || target === "") {
251
- throw unsafe(`symlink without a target: ${plan.effectivePath}`, {
252
- entryPath: plan.effectivePath,
253
- });
254
- }
255
- if (target.length > limits.maxSymlinkTargetLength) {
256
- throw unsafe(
257
- `symlink target longer than ${limits.maxSymlinkTargetLength} characters: ${plan.effectivePath}`,
258
- {
259
- entryPath: plan.effectivePath,
260
- },
261
- );
262
- }
263
- if (target.includes("\\")) {
264
- throw unsafe(
265
- `symlink target contains a backslash: ${plan.effectivePath}`,
266
- { entryPath: plan.effectivePath },
267
- );
268
- }
269
- if (target.startsWith("/") || /^[A-Za-z]:/.test(target)) {
270
- throw unsafe(`symlink target is absolute: ${plan.effectivePath}`, {
271
- entryPath: plan.effectivePath,
272
- target,
273
- });
274
- }
275
- assertTargetStaysInside(plan.effectivePath, target);
276
- }
277
-
278
- if (observedReserved > 0) {
279
- observations.push(
280
- `${observedReserved} path(s) use names reserved on Windows; deployment to Windows requires review`,
281
- );
282
- }
283
- if (observedWindowsInvalid > 0) {
284
- observations.push(
285
- `${observedWindowsInvalid} path(s) contain characters invalid on Windows; deployment to Windows requires review`,
286
- );
287
- }
288
-
289
- // ---- Pass 2: materialize ----
290
- const planByIndex = new Map(finalPlans.map((plan) => [plan.index, plan]));
291
- const symlinks = new Map<string, string>();
292
- const dirs = new Set<string>();
293
- const fileEntries = new Map<string, ExtractedFileEntry>();
294
- const entries: ExtractedEntry[] = [];
295
- let fileCount = 0;
296
- let dirCount = 0;
297
- let symlinkCount = 0;
298
- let logicalBytes = 0;
299
-
300
- await runParser(options.tarballPath, async (entry, index) => {
301
- const plan = planByIndex.get(index);
302
- if (plan === undefined) {
303
- // Entries removed by common-root stripping still must be drained.
304
- entry.resume();
305
- return;
306
- }
307
-
308
- switch (plan.type) {
309
- case "dir": {
310
- await ensureDirsFor(
311
- rootRelative(destRoot, plan.effectivePath),
312
- destRoot,
313
- symlinks,
314
- );
315
- dirs.add(plan.effectivePath);
316
- dirCount += 1;
317
- entries.push({ path: plan.effectivePath, type: "dir" });
318
- entry.resume();
319
- return;
320
- }
321
- case "file": {
322
- await ensureDirsFor(
323
- rootRelative(destRoot, path.dirname(plan.effectivePath)),
324
- destRoot,
325
- symlinks,
326
- );
327
- const observed = await writeEntryFile(
328
- entry,
329
- destRoot,
330
- plan.effectivePath,
331
- );
332
- if (observed.size !== plan.size) {
333
- await rm(observed.stagingPath, { force: true }).catch(() => {});
334
- throw unsafe(
335
- `file size mismatch while extracting ${plan.effectivePath}`,
336
- {
337
- entryPath: plan.effectivePath,
338
- declared: plan.size,
339
- actual: observed.size,
340
- },
341
- );
342
- }
343
- fileCount += 1;
344
- logicalBytes += observed.size;
345
- const fileEntry: ExtractedFileEntry = {
346
- path: plan.effectivePath,
347
- type: "file",
348
- mode: observed.mode,
349
- size: observed.size,
350
- innerDigest: observed.innerDigest,
351
- stagingPath: observed.stagingPath,
352
- };
353
- fileEntries.set(plan.effectivePath, fileEntry);
354
- entries.push(fileEntry);
355
- return;
356
- }
357
- case "symlink": {
358
- const target = plan.linkpath!;
359
- await ensureDirsFor(
360
- rootRelative(destRoot, path.dirname(plan.effectivePath)),
361
- destRoot,
362
- symlinks,
363
- );
364
- await fsSymlink(target, rootRelative(destRoot, plan.effectivePath));
365
- symlinks.set(plan.effectivePath, target);
366
- symlinkCount += 1;
367
- entries.push({ path: plan.effectivePath, type: "symlink", target });
368
- entry.resume();
369
- return;
370
- }
371
- case "hardlink": {
372
- const target = plan.linkpath!;
373
- const targetEntry = fileEntries.get(target);
374
- if (targetEntry === undefined) {
375
- throw unsafe(
376
- `hard link target was not extracted: ${plan.effectivePath} -> ${target}`,
377
- {
378
- entryPath: plan.effectivePath,
379
- target,
380
- },
381
- );
382
- }
383
- await ensureDirsFor(
384
- rootRelative(destRoot, path.dirname(plan.effectivePath)),
385
- destRoot,
386
- symlinks,
387
- );
388
- const dest = rootRelative(destRoot, plan.effectivePath);
389
- try {
390
- await fsLink(rootRelative(destRoot, target), dest);
391
- } catch (error) {
392
- throw unsafe(`could not create hard link ${plan.effectivePath}`, {
393
- entryPath: plan.effectivePath,
394
- errno: (error as NodeJS.ErrnoException).code,
395
- });
396
- }
397
- fileCount += 1;
398
- logicalBytes += targetEntry.size;
399
- const fileEntry: ExtractedFileEntry = {
400
- ...targetEntry,
401
- path: plan.effectivePath,
402
- stagingPath: dest,
403
- };
404
- fileEntries.set(plan.effectivePath, fileEntry);
405
- entries.push(fileEntry);
406
- entry.resume();
407
- return;
408
- }
409
- }
410
- });
411
-
412
- // Post-pass: chase every symlink chain so escapes through composed links
413
- // are detected even if each individual link looked safe.
414
- let dangling = 0;
415
- let cyclic = 0;
416
- for (const [linkPath, target] of symlinks) {
417
- const chased = chaseSymlinkChain(linkPath, target, symlinks);
418
- if (chased.escaped) {
419
- throw unsafe(`symlink chain escapes the package root: ${linkPath}`, {
420
- entryPath: linkPath,
421
- target,
422
- });
423
- }
424
- if (chased.cyclic) {
425
- cyclic += 1;
426
- continue;
427
- }
428
- if (
429
- !fileEntries.has(chased.finalPath) &&
430
- !dirs.has(chased.finalPath) &&
431
- !symlinks.has(chased.finalPath)
432
- ) {
433
- dangling += 1;
434
- }
435
- }
436
-
437
- entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
438
-
439
- const outcomes = new Set<string>([
440
- "traversal-checked",
441
- "absolute-paths-rejected",
442
- "case-collisions-rejected",
443
- "modes-normalized",
444
- "exclusive-creation",
445
- "symlink-closure-validated",
446
- ]);
447
- if (symlinkCount > 0) outcomes.add("symlinks-present");
448
- if (finalPlans.some((plan) => plan.type === "hardlink"))
449
- outcomes.add("hardlinks-present");
450
- if (dangling > 0) outcomes.add(`internal-dangling-symlinks:${dangling}`);
451
- if (cyclic > 0) outcomes.add(`cyclic-symlinks:${cyclic}`);
452
- if (commonRoot !== null) outcomes.add(`common-root-stripped:${commonRoot}`);
453
- if (observedReserved > 0)
454
- outcomes.add(`reserved-windows-names:${observedReserved}`);
455
- if (observedWindowsInvalid > 0)
456
- outcomes.add(`windows-invalid-characters:${observedWindowsInvalid}`);
457
-
458
- return {
459
- entries,
460
- totals: {
461
- files: fileCount,
462
- dirs: dirCount,
463
- symlinks: symlinkCount,
464
- logicalBytes,
465
- },
466
- outcomes: [...outcomes].sort(),
467
- commonRoot,
468
- observations,
469
- };
470
- }
471
-
472
- // ---------------------------------------------------------------------------
473
- // Path validation
474
- // ---------------------------------------------------------------------------
475
-
476
- export type PathCheck = {
477
- normalized: string;
478
- reserved: boolean;
479
- invalidWindows: boolean;
480
- };
481
-
482
- /** Normalize and validate an archive path; anything that can escape fails. */
483
- export function normalizeArchivePath(
484
- rawInput: string,
485
- limits: ExtractionLimits,
486
- ): PathCheck {
487
- const raw = rawInput.replace(/\/+$/, "");
488
- if (raw === "") {
489
- throw unsafe("archive contains an entry with an empty path", {
490
- entryPath: rawInput,
491
- });
492
- }
493
- if (raw.includes("\0")) {
494
- throw unsafe("path contains a NUL byte", { entryPath: rawInput });
495
- }
496
- if (raw.includes("\\")) {
497
- throw unsafe("path contains a backslash", { entryPath: rawInput });
498
- }
499
- if (raw.startsWith("/")) {
500
- throw unsafe("path is absolute", { entryPath: rawInput });
501
- }
502
- if (/^[A-Za-z]:/.test(raw)) {
503
- throw unsafe("path contains a drive letter", { entryPath: rawInput });
504
- }
505
-
506
- const segments: string[] = [];
507
- let reserved = false;
508
- let invalidWindows = false;
509
- for (const segment of raw.split("/")) {
510
- if (segment === "" || segment === ".") continue;
511
- if (segment === "..") {
512
- throw unsafe("path traverses upwards with '..'", { entryPath: rawInput });
513
- }
514
- if (Buffer.byteLength(segment, "utf8") > limits.maxComponentLength) {
515
- throw unsafe(
516
- `path component longer than ${limits.maxComponentLength} bytes`,
517
- { entryPath: rawInput },
518
- );
519
- }
520
- if (WINDOWS_RESERVED.test(segment) || /[. ]$/.test(segment))
521
- reserved = true;
522
- if (WINDOWS_INVALID_CHARS.test(segment)) invalidWindows = true;
523
- segments.push(segment);
524
- }
525
-
526
- if (segments.length === 0) {
527
- throw unsafe("path has no usable segments", { entryPath: rawInput });
528
- }
529
- const normalized = segments.join("/");
530
- if (normalized.length > limits.maxPathLength) {
531
- throw unsafe(`path longer than ${limits.maxPathLength} characters`, {
532
- entryPath: rawInput,
533
- });
534
- }
535
- return { normalized, reserved, invalidWindows };
536
- }
537
-
538
- function assertTargetStaysInside(linkPath: string, target: string): void {
539
- let level = linkPath.split("/").length - 1;
540
- for (const segment of target.split("/")) {
541
- if (segment === "" || segment === ".") continue;
542
- if (segment === "..") {
543
- level -= 1;
544
- if (level < 0) {
545
- throw unsafe(
546
- `link target escapes the package root: ${linkPath} -> ${target}`,
547
- {
548
- entryPath: linkPath,
549
- target,
550
- },
551
- );
552
- }
553
- continue;
554
- }
555
- level += 1;
556
- }
557
- }
558
-
559
- function findCommonRoot(plans: EntryPlan[]): string | null {
560
- let root: string | null = null;
561
- for (const plan of plans) {
562
- const first = plan.rawPath.split("/")[0];
563
- if (first === undefined) return null;
564
- if (plan.rawPath === first) {
565
- // An entry that *is* the candidate root must itself be a directory.
566
- if (plan.type !== "dir") return null;
567
- }
568
- if (root === null) root = first;
569
- else if (root !== first) return null;
570
- }
571
- return root;
572
- }
573
-
574
- function stripPrefix(entryPath: string, prefix: string): string {
575
- if (entryPath === prefix) return "";
576
- if (entryPath.startsWith(`${prefix}/`))
577
- return entryPath.slice(prefix.length + 1);
578
- return entryPath;
579
- }
580
-
581
- function assertNoDuplicateRawPaths(plans: EntryPlan[]): void {
582
- const seen = new Set<string>();
583
- for (const plan of plans) {
584
- if (seen.has(plan.rawPath)) {
585
- throw unsafe(
586
- `archive contains ${JSON.stringify(plan.rawPath)} more than once`,
587
- { entryPath: plan.rawPath },
588
- );
589
- }
590
- seen.add(plan.rawPath);
591
- }
592
- }
593
-
594
- function casefold(value: string): string {
595
- return value.toLowerCase();
596
- }
597
-
598
- // ---------------------------------------------------------------------------
599
- // Entry planning and materialization
600
- // ---------------------------------------------------------------------------
601
-
602
- function planType(entry: ReadEntry): EntryPlan["type"] {
603
- switch (entry.type) {
604
- case "File":
605
- case "OldFile":
606
- case "ContiguousFile":
607
- return "file";
608
- case "Directory":
609
- case "GNUDumpDir":
610
- return "dir";
611
- case "SymbolicLink":
612
- return "symlink";
613
- case "Link":
614
- return "hardlink";
615
- default:
616
- throw unsafe(
617
- `unsupported archive entry type ${entry.type} at ${entry.path}`,
618
- {
619
- entryPath: entry.path,
620
- entryType: entry.type,
621
- },
622
- );
623
- }
624
- }
625
-
626
- /** Root-relative path -> absolute path, rejecting escapes lexically once more. */
627
- function rootRelative(destRoot: string, relative: string): string {
628
- const resolved = path.resolve(destRoot, relative);
629
- if (resolved !== destRoot && !resolved.startsWith(`${destRoot}${path.sep}`)) {
630
- throw unsafe(`path escapes the extraction root: ${relative}`, {
631
- entryPath: relative,
632
- });
633
- }
634
- return resolved;
635
- }
636
-
637
- /**
638
- * Create the directory chain for an entry. Every ancestor is checked against
639
- * the set of symlinks created earlier in this extraction, so nothing is ever
640
- * written through a symlink (SPEC 13.4); directory entries themselves are
641
- * created exclusively.
642
- */
643
- async function ensureDirsFor(
644
- absDir: string,
645
- destRoot: string,
646
- symlinks: Map<string, string>,
647
- ): Promise<void> {
648
- if (absDir === destRoot) return;
649
- const relative = path.relative(destRoot, absDir);
650
- const segments = relative.split(path.sep);
651
- let current = "";
652
- for (const segment of segments) {
653
- current = current === "" ? segment : `${current}/${segment}`;
654
- if (symlinks.has(current)) {
655
- throw unsafe(
656
- `refusing to write through a symlink directory: ${current}`,
657
- { entryPath: current },
658
- );
659
- }
660
- }
661
- try {
662
- await fsMkdir(absDir, { recursive: true, mode: 0o755 });
663
- } catch (error) {
664
- if (!isErrno(error, "EEXIST")) {
665
- if (isErrno(error, "ENOTDIR")) {
666
- throw unsafe(`an ancestor of ${relative} is not a directory`, {
667
- entryPath: relative,
668
- });
669
- }
670
- throw error;
671
- }
672
- }
673
- const stat = await fsLstat(absDir);
674
- if (stat.isSymbolicLink()) {
675
- throw unsafe(`refusing to use a symlink as a directory: ${relative}`, {
676
- entryPath: relative,
677
- });
678
- }
679
- if (!stat.isDirectory()) {
680
- throw unsafe(`an entry collides with an existing file: ${relative}`, {
681
- entryPath: relative,
682
- });
683
- }
684
- }
685
-
686
- async function writeEntryFile(
687
- entry: ReadEntry,
688
- destRoot: string,
689
- relative: string,
690
- ): Promise<{
691
- mode: ModeClass;
692
- size: number;
693
- innerDigest: string;
694
- stagingPath: string;
695
- }> {
696
- const dest = rootRelative(destRoot, relative);
697
- const declaredMode = typeof entry.mode === "number" ? entry.mode : 0o644;
698
- const modeClass = modeClassOf(declaredMode);
699
- const fileMode = modeClass === "exec" ? 0o755 : 0o644;
700
-
701
- // 'wx' never follows an existing symlink; any existing entry is an error.
702
- let handle;
703
- try {
704
- handle = await open(dest, "wx", fileMode);
705
- } catch (error) {
706
- if (isErrno(error, "EEXIST")) {
707
- throw unsafe(`refusing to overwrite an existing entry: ${relative}`, {
708
- entryPath: relative,
709
- });
710
- }
711
- throw error;
712
- }
713
-
714
- const digest = new Digest();
715
- let size = 0;
716
- try {
717
- for await (const chunk of entry) {
718
- const buffer = Buffer.isBuffer(chunk)
719
- ? chunk
720
- : Buffer.from(chunk as Uint8Array);
721
- size += buffer.length;
722
- digest.update(buffer);
723
- await handle.write(buffer);
724
- }
725
- await handle.sync();
726
- } catch (error) {
727
- await handle.close().catch(() => {});
728
- await rm(dest, { force: true }).catch(() => {});
729
- throw error;
730
- }
731
- await handle.close();
732
- await chmod(dest, fileMode); // archive modes are advisory; bits are normalized
733
- return {
734
- mode: modeClass,
735
- size,
736
- innerDigest: digest.hex(),
737
- stagingPath: dest,
738
- };
739
- }
740
-
741
- // ---------------------------------------------------------------------------
742
- // Symlink chain chasing (defense in depth, bounded)
743
- // ---------------------------------------------------------------------------
744
-
745
- const MAX_CHASE_HOPS = 40;
746
-
747
- export function chaseSymlinkChain(
748
- linkPath: string,
749
- target: string,
750
- symlinks: Map<string, string>,
751
- ): { finalPath: string; escaped: boolean; cyclic: boolean } {
752
- const out: string[] = [];
753
- let rest = [...linkPath.split("/").slice(0, -1), ...target.split("/")];
754
- let hops = 0;
755
-
756
- while (rest.length > 0) {
757
- const segment = rest.shift()!;
758
- if (segment === "" || segment === ".") continue;
759
- if (segment === "..") {
760
- if (out.length === 0)
761
- return { finalPath: "", escaped: true, cyclic: false };
762
- out.pop();
763
- continue;
764
- }
765
- out.push(segment);
766
- const candidate = out.join("/");
767
- const hopTarget = symlinks.get(candidate);
768
- if (hopTarget !== undefined) {
769
- hops += 1;
770
- if (hops > MAX_CHASE_HOPS) {
771
- return { finalPath: candidate, escaped: false, cyclic: true };
772
- }
773
- out.pop();
774
- rest = [...hopTarget.split("/"), ...rest];
775
- }
776
- }
777
- return { finalPath: out.join("/"), escaped: false, cyclic: false };
778
- }
779
-
780
- // ---------------------------------------------------------------------------
781
- // Parser driver
782
- // ---------------------------------------------------------------------------
783
-
784
- /**
785
- * Run the tar parser over the archive, invoking `handler` for each data
786
- * entry. Entries are processed strictly serially: the parser does not read
787
- * the next entry until the handler has finished with (or resumed) the
788
- * current one, which keeps memory bounded on large archives.
789
- */
790
- async function runParser(
791
- tarballPath: string,
792
- handler: (entry: ReadEntry, index: number) => Promise<void>,
793
- ): Promise<void> {
794
- await new Promise<void>((resolve, reject) => {
795
- let index = 0;
796
- let chain: Promise<void> = Promise.resolve();
797
- let failed = false;
798
-
799
- const stream = createReadStream(tarballPath);
800
- const finish = (error?: Error): void => {
801
- if (error !== undefined) {
802
- failed = true;
803
- stream.destroy();
804
- reject(error);
805
- } else {
806
- resolve();
807
- }
808
- };
809
-
810
- // tar v7 delivers entries through the `entry` event; each entry starts
811
- // paused and the handler must resume/consume it, which the parser's flow
812
- // control (and therefore our bounded memory) depends on.
813
- const parser = new Parser({
814
- strict: true,
815
- maxMetaEntrySize: 1024 * 1024,
816
- maxDecompressionRatio: 2000,
817
- });
818
- parser.on("entry", (entry: ReadEntry) => {
819
- if (entry.meta === true) {
820
- entry.resume();
821
- return;
822
- }
823
- const entryIndex = index++;
824
- chain = chain
825
- .then(() => {
826
- if (failed) {
827
- entry.resume();
828
- return;
829
- }
830
- return handler(entry, entryIndex).catch((error: unknown) => {
831
- failed = true;
832
- parser.abort(
833
- error instanceof Error ? error : new Error(String(error)),
834
- );
835
- throw error;
836
- });
837
- })
838
- // Handler failures reach the caller through the parser's error event
839
- // (abort always emits one); swallowing here only keeps the serial
840
- // chain from becoming an unhandled rejection after the abort.
841
- .catch(() => {});
842
- });
843
-
844
- parser.on("error", (error: Error) => {
845
- finish(
846
- error instanceof ZyrkError
847
- ? error
848
- : unsafe(`archive could not be parsed: ${error.message}`, {
849
- archiveError: error.message,
850
- }),
851
- );
852
- });
853
- parser.on("end", () => {
854
- chain.then(() => finish()).catch(() => {});
855
- });
856
-
857
- stream.on("error", (error: Error) => finish(error));
858
- stream.pipe(parser);
859
- });
860
- }
861
-
862
- function unsafe(message: string, detail: Record<string, unknown>): ZyrkError {
863
- return new ZyrkError("ZYRK_EXTRACTION_UNSAFE", message, {
864
- detail,
865
- remedy:
866
- "review the package contents or report the package; zyrk will not extract archives that violate the extraction safety rules",
867
- });
868
- }