@openclaw/fs-safe 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -0
  3. package/dist/archive-deadline.d.ts +3 -0
  4. package/dist/archive-deadline.d.ts.map +1 -1
  5. package/dist/archive-deadline.js +44 -8
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +1 -0
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +3 -0
  11. package/dist/archive-input.d.ts.map +1 -1
  12. package/dist/archive-input.js +26 -16
  13. package/dist/archive-limits.d.ts +11 -3
  14. package/dist/archive-limits.d.ts.map +1 -1
  15. package/dist/archive-limits.js +24 -0
  16. package/dist/archive-native.d.ts +3 -2
  17. package/dist/archive-native.d.ts.map +1 -1
  18. package/dist/archive-native.js +24 -9
  19. package/dist/archive-policy.d.ts +2 -0
  20. package/dist/archive-policy.d.ts.map +1 -1
  21. package/dist/archive-policy.js +9 -1
  22. package/dist/archive-read.d.ts.map +1 -1
  23. package/dist/archive-read.js +69 -41
  24. package/dist/archive-staging.d.ts +3 -0
  25. package/dist/archive-staging.d.ts.map +1 -1
  26. package/dist/archive-staging.js +91 -43
  27. package/dist/archive-tar-admission.d.ts +7 -0
  28. package/dist/archive-tar-admission.d.ts.map +1 -0
  29. package/dist/archive-tar-admission.js +43 -0
  30. package/dist/archive-tar-gnu.d.ts +2 -0
  31. package/dist/archive-tar-gnu.d.ts.map +1 -0
  32. package/dist/archive-tar-gnu.js +20 -0
  33. package/dist/archive-tar-header.d.ts +8 -0
  34. package/dist/archive-tar-header.d.ts.map +1 -0
  35. package/dist/archive-tar-header.js +47 -0
  36. package/dist/archive-tar-meta.d.ts +29 -1
  37. package/dist/archive-tar-meta.d.ts.map +1 -1
  38. package/dist/archive-tar-meta.js +104 -35
  39. package/dist/archive-tar-runtime.d.ts +3 -0
  40. package/dist/archive-tar-runtime.d.ts.map +1 -1
  41. package/dist/archive-tar-runtime.js +3 -0
  42. package/dist/archive-tar.d.ts.map +1 -1
  43. package/dist/archive-tar.js +8 -2
  44. package/dist/archive-zip-names.js +2 -2
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +38 -26
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/bounded-read-stream.d.ts.map +1 -1
  50. package/dist/bounded-read-stream.js +2 -4
  51. package/dist/bounded-read.d.ts.map +1 -1
  52. package/dist/bounded-read.js +3 -10
  53. package/dist/byte-budget.d.ts +5 -0
  54. package/dist/byte-budget.d.ts.map +1 -0
  55. package/dist/byte-budget.js +9 -0
  56. package/dist/file-lock-sync.d.ts.map +1 -1
  57. package/dist/file-lock-sync.js +10 -5
  58. package/dist/file-store-boundary.d.ts.map +1 -1
  59. package/dist/file-store-boundary.js +9 -5
  60. package/dist/file-store-limit.d.ts +2 -0
  61. package/dist/file-store-limit.d.ts.map +1 -0
  62. package/dist/file-store-limit.js +8 -0
  63. package/dist/file-store-sync-write.d.ts.map +1 -1
  64. package/dist/file-store-sync-write.js +38 -8
  65. package/dist/file-store.d.ts.map +1 -1
  66. package/dist/file-store.js +26 -26
  67. package/dist/json-durable-queue-directory.d.ts +2 -0
  68. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  69. package/dist/json-durable-queue-directory.js +20 -0
  70. package/dist/json-durable-queue-ownership.d.ts +14 -0
  71. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  72. package/dist/json-durable-queue-ownership.js +168 -0
  73. package/dist/json-durable-queue-retirement.d.ts +9 -0
  74. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  75. package/dist/json-durable-queue-retirement.js +126 -0
  76. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  77. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  78. package/dist/json-durable-queue-transfer-lock.js +19 -0
  79. package/dist/json-durable-queue.d.ts +1 -0
  80. package/dist/json-durable-queue.d.ts.map +1 -1
  81. package/dist/json-durable-queue.js +90 -57
  82. package/dist/json.d.ts.map +1 -1
  83. package/dist/json.js +27 -8
  84. package/dist/local-roots.d.ts.map +1 -1
  85. package/dist/local-roots.js +4 -2
  86. package/dist/native-binding.d.ts +12 -3
  87. package/dist/native-binding.d.ts.map +1 -1
  88. package/dist/output-sibling.d.ts.map +1 -1
  89. package/dist/output-sibling.js +11 -110
  90. package/dist/output.d.ts.map +1 -1
  91. package/dist/output.js +4 -2
  92. package/dist/pinned-open.d.ts.map +1 -1
  93. package/dist/pinned-open.js +27 -48
  94. package/dist/pinned-write.d.ts.map +1 -1
  95. package/dist/pinned-write.js +8 -9
  96. package/dist/private-temp-workspace.d.ts +3 -1
  97. package/dist/private-temp-workspace.d.ts.map +1 -1
  98. package/dist/private-temp-workspace.js +81 -56
  99. package/dist/publish-file.d.ts.map +1 -1
  100. package/dist/publish-file.js +2 -4
  101. package/dist/read-opened-file.d.ts.map +1 -1
  102. package/dist/read-opened-file.js +6 -4
  103. package/dist/regular-file.d.ts.map +1 -1
  104. package/dist/regular-file.js +136 -82
  105. package/dist/replace-file-copy-fallback.d.ts +3 -1
  106. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  107. package/dist/replace-file-copy-fallback.js +21 -36
  108. package/dist/replace-file-copy-source.d.ts +21 -0
  109. package/dist/replace-file-copy-source.d.ts.map +1 -0
  110. package/dist/replace-file-copy-source.js +112 -0
  111. package/dist/replace-file-descriptor.d.ts +13 -3
  112. package/dist/replace-file-descriptor.d.ts.map +1 -1
  113. package/dist/replace-file-descriptor.js +32 -7
  114. package/dist/replace-file-rename-policy.d.ts +7 -0
  115. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  116. package/dist/replace-file-rename-policy.js +30 -0
  117. package/dist/replace-file-temp-owner.d.ts +46 -0
  118. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  119. package/dist/replace-file-temp-owner.js +346 -0
  120. package/dist/replace-file.d.ts +6 -1
  121. package/dist/replace-file.d.ts.map +1 -1
  122. package/dist/replace-file.js +72 -58
  123. package/dist/root-impl.d.ts.map +1 -1
  124. package/dist/root-impl.js +40 -26
  125. package/dist/secret-file.d.ts.map +1 -1
  126. package/dist/secret-file.js +6 -3
  127. package/dist/secret-read-async.d.ts.map +1 -1
  128. package/dist/secret-read-async.js +6 -5
  129. package/dist/secure-file.d.ts.map +1 -1
  130. package/dist/secure-file.js +17 -13
  131. package/dist/sibling-staged-file.d.ts +15 -0
  132. package/dist/sibling-staged-file.d.ts.map +1 -0
  133. package/dist/sibling-staged-file.js +148 -0
  134. package/dist/sibling-temp.d.ts +3 -0
  135. package/dist/sibling-temp.d.ts.map +1 -1
  136. package/dist/sibling-temp.js +30 -74
  137. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  138. package/dist/sidecar-lock-acquire.js +48 -27
  139. package/dist/sidecar-lock-handle.d.ts +6 -2
  140. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  141. package/dist/sidecar-lock-handle.js +17 -3
  142. package/dist/sidecar-lock-policy.d.ts +2 -0
  143. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  144. package/dist/sidecar-lock-policy.js +29 -0
  145. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  146. package/dist/sidecar-lock-reclaim.js +29 -7
  147. package/dist/sidecar-lock.d.ts.map +1 -1
  148. package/dist/sidecar-lock.js +28 -16
  149. package/dist/suppressed-error.d.ts +6 -0
  150. package/dist/suppressed-error.d.ts.map +1 -0
  151. package/dist/suppressed-error.js +15 -0
  152. package/dist/temp-cleanup.d.ts +2 -0
  153. package/dist/temp-cleanup.d.ts.map +1 -1
  154. package/dist/temp-cleanup.js +25 -10
  155. package/dist/temp-workspace-owner.d.ts +23 -0
  156. package/dist/temp-workspace-owner.d.ts.map +1 -0
  157. package/dist/temp-workspace-owner.js +320 -0
  158. package/dist/temp.d.ts +1 -1
  159. package/dist/temp.d.ts.map +1 -1
  160. package/dist/test-hooks.d.ts +5 -0
  161. package/dist/test-hooks.d.ts.map +1 -1
  162. package/docs/advanced.md +8 -1
  163. package/docs/archive.md +194 -26
  164. package/docs/atomic.md +11 -2
  165. package/docs/durability.md +1 -1
  166. package/docs/file-store.md +2 -0
  167. package/docs/install.md +8 -4
  168. package/docs/json.md +8 -4
  169. package/docs/native-helper.md +15 -3
  170. package/docs/native.md +33 -6
  171. package/docs/output.md +10 -0
  172. package/docs/public-api.md +6 -3
  173. package/docs/quickstart.md +1 -1
  174. package/docs/reading.md +1 -1
  175. package/docs/regular-file.md +9 -2
  176. package/docs/root.md +2 -0
  177. package/docs/secret-file.md +5 -1
  178. package/docs/secure-file.md +4 -2
  179. package/docs/security-model.md +18 -0
  180. package/docs/sidecar-lock.md +4 -0
  181. package/docs/store.md +13 -0
  182. package/docs/temp.md +128 -14
  183. package/package.json +10 -10
@@ -1,29 +1,32 @@
1
- import fsSync from "node:fs";
2
1
  import fs from "node:fs/promises";
3
2
  import { Readable } from "node:stream";
4
3
  import { readFileHandleBounded } from "./bounded-read.js";
5
- import { ArchiveFormatError, ArchiveSecurityError, isArchiveFormatErrorMessage, } from "./archive-errors.js";
4
+ import { ArchiveFormatError, ArchiveSecurityError, isArchiveFormatErrorMessage, isArchiveTarPathErrorMessage, } from "./archive-errors.js";
6
5
  import { formatErrorDetail } from "./error-detail.js";
7
- import { normalizeArchiveEntryPath, validateArchiveEntryPath, } from "./archive-entry.js";
6
+ import { stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
8
7
  import { resolveArchiveKind } from "./archive-kind.js";
9
- import { DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_META_ENTRY_BYTES, ArchiveLimitError, ARCHIVE_LIMIT_ERROR_CODE, } from "./archive-limits.js";
8
+ import { DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_META_ENTRY_BYTES, ArchiveLimitError, ARCHIVE_LIMIT_ERROR_CODE, } from "./archive-limits.js";
10
9
  import { readTarEntryInfo } from "./archive-tar.js";
11
10
  import { preflightTarMetadata } from "./archive-tar-meta.js";
12
- import { importOptionalTar, normalizeTarParserError } from "./archive-tar-runtime.js";
11
+ import { importOptionalTar, NODE_TAR_RATIO_DISABLED, normalizeTarParserError } from "./archive-tar-runtime.js";
13
12
  import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js";
14
13
  import { createZipIntegrityTransform, normalizeZipIntegrityError, } from "./archive-zip-integrity.js";
15
14
  import { FsSafeError } from "./errors.js";
16
- import { sameFileIdentity } from "./file-identity.js";
15
+ import { inspectFileIdentity } from "./strict-file-identity.js";
16
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
17
17
  import { getNativeBinding } from "./native.js";
18
18
  import { admitZipBuffer } from "./archive-zip-admission.js";
19
- import { resolveExtractLimits } from "./archive-limits.js";
19
+ import { resolveExtractLimits, resolveTarMeterLimits } from "./archive-limits.js";
20
20
  import { tempFile } from "./temp-target.js";
21
21
  const ZIP_UNIX_FILE_TYPE_MASK = 0o170000;
22
22
  const ZIP_UNIX_SYMLINK_TYPE = 0o120000;
23
- function normalizedRequestedEntry(entryPath) {
23
+ function canonicalEntryPath(entryPath) {
24
24
  validateArchiveEntryPath(entryPath, { escapeLabel: "archive root" });
25
- const normalized = normalizeArchiveEntryPath(entryPath).replace(/^\.\//, "");
26
- if (!normalized || normalized.endsWith("/")) {
25
+ return stripArchivePath(entryPath, 0) ?? "";
26
+ }
27
+ function normalizedRequestedEntry(entryPath) {
28
+ const normalized = canonicalEntryPath(entryPath);
29
+ if (!normalized || /[/\\]$/.test(entryPath)) {
27
30
  throw new Error(`archive entry is not a file: ${formatErrorDetail(entryPath)}`);
28
31
  }
29
32
  return normalized;
@@ -62,30 +65,37 @@ async function readStreamBounded(stream, maxBytes) {
62
65
  }
63
66
  async function stageArchiveInput(archivePath) {
64
67
  const resolved = await fs.realpath(archivePath);
65
- const before = await fs.lstat(archivePath);
66
- if (before.isSymbolicLink() || !before.isFile()) {
67
- throw new Error(`archive is not a regular file: ${archivePath}`);
68
- }
69
- const noFollow = process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
70
- ? fsSync.constants.O_NOFOLLOW
71
- : 0;
72
- const handle = await fs.open(resolved, fsSync.constants.O_RDONLY | noFollow);
68
+ const before = await inspectFileIdentity(async () => {
69
+ const stat = await fs.lstat(archivePath, { bigint: true });
70
+ if (stat.isSymbolicLink() || !stat.isFile()) {
71
+ throw new Error(`archive is not a regular file: ${archivePath}`);
72
+ }
73
+ return stat;
74
+ });
75
+ const handle = await fs.open(resolved, resolveReadOpenFlags());
73
76
  const staged = await tempFile({ prefix: "fs-safe-archive-read", fileName: "archive.bin" });
74
77
  try {
75
- const opened = await handle.stat();
76
- const current = await fs.lstat(resolved);
77
- if (!opened.isFile() ||
78
- !current.isFile() ||
79
- !sameFileIdentity(before, opened) ||
80
- !sameFileIdentity(current, opened)) {
81
- throw new Error("archive changed during validation");
82
- }
78
+ const opened = await inspectFileIdentity(async () => {
79
+ const stat = await handle.stat({ bigint: true });
80
+ if (!stat.isFile())
81
+ throw new Error("archive changed during validation");
82
+ return stat;
83
+ }, before);
84
+ await inspectFileIdentity(async () => {
85
+ const stat = await fs.lstat(resolved, { bigint: true });
86
+ if (stat.isSymbolicLink() || !stat.isFile())
87
+ throw new Error("archive changed during validation");
88
+ return stat;
89
+ }, opened);
83
90
  const buffer = await readFileHandleBounded(handle, DEFAULT_MAX_ARCHIVE_BYTES_ZIP);
84
91
  await fs.writeFile(staged.path, buffer, { flag: "wx", mode: 0o600 });
85
92
  return { path: staged.path, buffer, cleanup: staged.cleanup };
86
93
  }
87
94
  catch (error) {
88
95
  await staged.cleanup().catch(() => undefined);
96
+ if (error instanceof FsSafeError && error.code === "path-mismatch") {
97
+ throw new FsSafeError("path-mismatch", "archive changed during validation", { cause: error });
98
+ }
89
99
  throw error;
90
100
  }
91
101
  finally {
@@ -98,7 +108,17 @@ async function readZipEntry(buffer, entryPath, maxBytes) {
98
108
  maxEntryBytes: maxBytes,
99
109
  maxExtractedBytes: maxBytes,
100
110
  });
101
- const entry = archive.files[entryPath];
111
+ let entry;
112
+ // JSZip keys retain some aliases and may use Unicode Path metadata. Scan the
113
+ // effective entries once, after raw ZIP admission has rejected collisions.
114
+ for (const candidate of Object.values(archive.files)) {
115
+ if (canonicalEntryPath(candidate.name) !== entryPath)
116
+ continue;
117
+ if (entry) {
118
+ throw new ArchiveSecurityError("entry-path", `archive contains duplicate entry path: ${formatErrorDetail(entryPath)}`);
119
+ }
120
+ entry = candidate;
121
+ }
102
122
  if (!entry || entry.dir) {
103
123
  throw new Error(`archive entry not found: ${formatErrorDetail(entryPath)}`);
104
124
  }
@@ -115,22 +135,34 @@ async function readZipEntry(buffer, entryPath, maxBytes) {
115
135
  }
116
136
  async function readTarEntry(archivePath, entryPath, maxBytes) {
117
137
  const tar = await importOptionalTar();
138
+ const seenPaths = new Set();
118
139
  await preflightTarMetadata({
119
140
  archivePath,
120
- maxMetaEntryBytes: DEFAULT_MAX_META_ENTRY_BYTES,
141
+ limits: resolveTarMeterLimits(),
142
+ onMember(info) {
143
+ const normalized = canonicalEntryPath(info.path);
144
+ if (seenPaths.has(normalized)) {
145
+ throw new ArchiveSecurityError("entry-path", `archive contains duplicate entry path: ${formatErrorDetail(normalized)}`);
146
+ }
147
+ seenPaths.add(normalized);
148
+ if (normalized === entryPath && !["File", "OldFile", "ContiguousFile"].includes(info.type)) {
149
+ throw new Error(`archive entry is not a file: ${formatErrorDetail(entryPath)}`);
150
+ }
151
+ },
121
152
  });
122
153
  let matched;
123
154
  let entryError;
124
- const seenPaths = new Set();
125
155
  try {
126
156
  await tar.t({
127
157
  file: archivePath,
128
158
  strict: true,
129
159
  maxMetaEntrySize: DEFAULT_MAX_META_ENTRY_BYTES,
160
+ maxDecompressionRatio: NODE_TAR_RATIO_DISABLED,
130
161
  onReadEntry(entry) {
131
162
  const info = readTarEntryInfo(entry);
163
+ let normalized;
132
164
  try {
133
- validateArchiveEntryPath(info.path, { escapeLabel: "archive root" });
165
+ normalized = canonicalEntryPath(info.path);
134
166
  }
135
167
  catch (error) {
136
168
  // Throws escaping node-tar's callback do not reject its promise.
@@ -138,13 +170,6 @@ async function readTarEntry(archivePath, entryPath, maxBytes) {
138
170
  entry.resume();
139
171
  return;
140
172
  }
141
- const normalized = normalizeArchiveEntryPath(info.path).replace(/^\.\//, "");
142
- if (seenPaths.has(normalized)) {
143
- entryError ??= new ArchiveSecurityError("entry-path", `archive contains duplicate entry path: ${formatErrorDetail(normalized)}`);
144
- entry.resume();
145
- return;
146
- }
147
- seenPaths.add(normalized);
148
173
  if (normalized !== entryPath) {
149
174
  entry.resume();
150
175
  return;
@@ -192,15 +217,15 @@ export async function readArchiveEntry(archivePath, entryPath, options) {
192
217
  if (native) {
193
218
  try {
194
219
  const signal = new AbortController().signal;
195
- const manifest = await native.inspectArchiveNative(staged.path, kind, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_META_ENTRY_BYTES, DEFAULT_MAX_ARCHIVE_BYTES_ZIP, signal);
220
+ const limits = resolveTarMeterLimits();
221
+ const manifest = await native.inspectArchiveNative(staged.path, kind, limits, signal);
196
222
  let rawEntryPath;
197
223
  if (physicalCount !== undefined && manifest.length !== physicalCount) {
198
224
  throw new ArchiveSecurityError("entry-path", "zip decoder collapsed entry names");
199
225
  }
200
226
  const seenPaths = new Set();
201
227
  for (const entry of manifest) {
202
- validateArchiveEntryPath(entry.path, { escapeLabel: "archive root" });
203
- const normalized = normalizeArchiveEntryPath(entry.path).replace(/^\.\//, "");
228
+ const normalized = canonicalEntryPath(entry.path);
204
229
  if (seenPaths.has(normalized)) {
205
230
  throw new ArchiveSecurityError("entry-path", `archive contains duplicate entry path: ${formatErrorDetail(normalized)}`);
206
231
  }
@@ -215,9 +240,12 @@ export async function readArchiveEntry(archivePath, entryPath, options) {
215
240
  if (!rawEntryPath) {
216
241
  throw new Error(`archive entry not found: ${formatErrorDetail(entryPath)}`);
217
242
  }
218
- return await native.readArchiveEntryNative(staged.path, kind, rawEntryPath, options.maxBytes, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_META_ENTRY_BYTES, signal);
243
+ return await native.readArchiveEntryNative(staged.path, kind, rawEntryPath, options.maxBytes, limits, signal);
219
244
  }
220
245
  catch (error) {
246
+ if (error instanceof Error && isArchiveTarPathErrorMessage(error.message)) {
247
+ throw new ArchiveSecurityError("entry-path", error.message, { cause: error });
248
+ }
221
249
  if (error instanceof Error) {
222
250
  for (const code of Object.values(ARCHIVE_LIMIT_ERROR_CODE)) {
223
251
  if (error.message.includes(code))
@@ -1,3 +1,4 @@
1
+ import { type ExtractionDeadline } from "./archive-deadline.js";
1
2
  import { ArchiveSecurityError } from "./archive-errors.js";
2
3
  export { ArchiveSecurityError, type ArchiveSecurityErrorCode } from "./archive-errors.js";
3
4
  export declare function prepareArchiveDestinationDir(destDir: string): Promise<string>;
@@ -8,6 +9,7 @@ export declare function prepareArchiveOutputPath(params: {
8
9
  outPath: string;
9
10
  originalPath: string;
10
11
  isDirectory: boolean;
12
+ deadline?: ExtractionDeadline;
11
13
  }): Promise<void>;
12
14
  export declare function withStagedArchiveDestination<T>(params: {
13
15
  destinationRealDir: string;
@@ -18,6 +20,7 @@ export declare function mergeExtractedTreeIntoDestination(params: {
18
20
  sourceDir: string;
19
21
  destinationDir: string;
20
22
  destinationRealDir: string;
23
+ deadline?: ExtractionDeadline;
21
24
  }): Promise<void>;
22
25
  export declare function createArchiveSymlinkTraversalError(originalPath: string): ArchiveSecurityError;
23
26
  //# sourceMappingURL=archive-staging.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,oBAAoB,EAErB,MAAM,qBAAqB,CAAC;AAW7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAkC1F,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA4DD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChB;AAyHD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CA6Bb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAkFhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
1
+ {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAOA,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,oBAAoB,EAErB,MAAM,qBAAqB,CAAC;AAgB7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAkC1F,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA4DD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,kBAAkB,CAAC;CAC/B,GAAG,OAAO,CAAC,IAAI,CAAC,CA6DhB;AAsID,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CA6Bb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,kBAAkB,CAAC;CAC/B,GAAG,OAAO,CAAC,IAAI,CAAC,CA8FhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
@@ -1,16 +1,20 @@
1
1
  import fs from "node:fs/promises";
2
- import fsSync from "node:fs";
3
2
  import path from "node:path";
4
3
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
4
+ import { ownExtractionDestinationMutation, } from "./archive-deadline.js";
5
5
  import { ArchiveSecurityError, } from "./archive-errors.js";
6
6
  import { FsSafeError } from "./errors.js";
7
7
  import { formatErrorDetail } from "./error-detail.js";
8
8
  import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
9
9
  import { isNotFoundPathError, isPathInside } from "./path.js";
10
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
10
11
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
11
12
  import { getFsSafeTestHooks } from "./test-hooks.js";
12
13
  const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
13
14
  const ARCHIVE_STAGING_MODE = 0o700;
15
+ function checkExtractionDeadline(deadline) {
16
+ deadline?.check();
17
+ }
14
18
  export { ArchiveSecurityError } from "./archive-errors.js";
15
19
  function symlinkTraversalError(originalPath) {
16
20
  return new ArchiveSecurityError("destination-symlink-traversal", `${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${formatErrorDetail(originalPath)}`);
@@ -105,55 +109,79 @@ async function mkdirArchiveOutput(params) {
105
109
  }
106
110
  }
107
111
  export async function prepareArchiveOutputPath(params) {
112
+ checkExtractionDeadline(params.deadline);
108
113
  const targetRoot = await root(params.destinationRealDir);
114
+ checkExtractionDeadline(params.deadline);
109
115
  const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
116
+ checkExtractionDeadline(params.deadline);
110
117
  const relPath = params.relPath.split(path.sep).join(path.posix.sep);
111
118
  await assertNoSymlinkTraversal({
112
119
  rootDir: params.destinationDir,
113
120
  relPath,
114
121
  originalPath: params.originalPath,
115
122
  });
123
+ checkExtractionDeadline(params.deadline);
116
124
  if (params.isDirectory) {
117
125
  await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", params.outPath);
118
- await assertDirectoryIdentityGuard(destinationGuard);
119
- await mkdirArchiveOutput({ targetRoot, relativePath: relPath, originalPath: params.originalPath });
120
- await assertDirectoryIdentityGuard(destinationGuard);
121
- await assertResolvedInsideDestination({
122
- destinationRealDir: params.destinationRealDir,
123
- targetPath: params.outPath,
124
- originalPath: params.originalPath,
126
+ checkExtractionDeadline(params.deadline);
127
+ await ownExtractionDestinationMutation(params.deadline, async () => {
128
+ await assertDirectoryIdentityGuard(destinationGuard);
129
+ checkExtractionDeadline(params.deadline);
130
+ await mkdirArchiveOutput({
131
+ targetRoot,
132
+ relativePath: relPath,
133
+ originalPath: params.originalPath,
134
+ });
135
+ checkExtractionDeadline(params.deadline);
136
+ await assertDirectoryIdentityGuard(destinationGuard);
137
+ checkExtractionDeadline(params.deadline);
138
+ await assertResolvedInsideDestination({
139
+ destinationRealDir: params.destinationRealDir,
140
+ targetPath: params.outPath,
141
+ originalPath: params.originalPath,
142
+ });
143
+ checkExtractionDeadline(params.deadline);
125
144
  });
126
145
  return;
127
146
  }
128
147
  const parentRel = path.posix.dirname(relPath);
129
148
  if (parentRel !== ".") {
130
149
  await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", path.dirname(params.outPath));
131
- await assertDirectoryIdentityGuard(destinationGuard);
132
- await mkdirArchiveOutput({
133
- targetRoot,
134
- relativePath: parentRel,
135
- originalPath: params.originalPath,
150
+ checkExtractionDeadline(params.deadline);
151
+ await ownExtractionDestinationMutation(params.deadline, async () => {
152
+ await assertDirectoryIdentityGuard(destinationGuard);
153
+ checkExtractionDeadline(params.deadline);
154
+ await mkdirArchiveOutput({
155
+ targetRoot,
156
+ relativePath: parentRel,
157
+ originalPath: params.originalPath,
158
+ });
159
+ checkExtractionDeadline(params.deadline);
160
+ await assertDirectoryIdentityGuard(destinationGuard);
161
+ checkExtractionDeadline(params.deadline);
136
162
  });
137
- await assertDirectoryIdentityGuard(destinationGuard);
138
163
  }
139
164
  await assertResolvedInsideDestination({
140
165
  destinationRealDir: params.destinationRealDir,
141
166
  targetPath: path.dirname(params.outPath),
142
167
  originalPath: params.originalPath,
143
168
  });
169
+ checkExtractionDeadline(params.deadline);
144
170
  }
145
171
  async function chmodInsideDestinationBestEffort(params) {
146
172
  await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("chmod", params.destinationPath);
173
+ checkExtractionDeadline(params.deadline);
147
174
  const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
175
+ checkExtractionDeadline(params.deadline);
148
176
  await assertDirectoryIdentityGuard(destinationGuard);
149
- const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
150
- ? fsSync.constants.O_NOFOLLOW
151
- : 0;
177
+ checkExtractionDeadline(params.deadline);
152
178
  const handle = await fs
153
- .open(params.destinationPath, fsSync.constants.O_RDONLY | noFollowFlag)
179
+ .open(params.destinationPath, resolveReadOpenFlags())
154
180
  .catch(() => null);
181
+ checkExtractionDeadline(params.deadline);
155
182
  if (!handle) {
156
183
  const stat = await fs.lstat(params.destinationPath).catch(() => null);
184
+ checkExtractionDeadline(params.deadline);
157
185
  if (stat?.isSymbolicLink()) {
158
186
  throw symlinkTraversalError(params.originalPath);
159
187
  }
@@ -161,35 +189,45 @@ async function chmodInsideDestinationBestEffort(params) {
161
189
  }
162
190
  try {
163
191
  const stat = await handle.stat();
192
+ checkExtractionDeadline(params.deadline);
164
193
  if (!stat.isDirectory() && !stat.isFile()) {
165
194
  return;
166
195
  }
167
196
  const realPath = await resolveOpenedFileRealPathForHandle(handle, params.destinationPath);
197
+ checkExtractionDeadline(params.deadline);
168
198
  if (!isPathInside(params.destinationRealDir, realPath)) {
169
199
  throw symlinkTraversalError(params.originalPath);
170
200
  }
171
- await handle.chmod(params.mode).catch(() => undefined);
172
- await assertDirectoryIdentityGuard(destinationGuard);
201
+ await ownExtractionDestinationMutation(params.deadline, async () => {
202
+ await handle.chmod(params.mode).catch(() => undefined);
203
+ checkExtractionDeadline(params.deadline);
204
+ await assertDirectoryIdentityGuard(destinationGuard);
205
+ checkExtractionDeadline(params.deadline);
206
+ });
173
207
  }
174
208
  finally {
175
209
  await handle.close().catch(() => undefined);
176
210
  }
177
211
  }
178
212
  async function applyStagedEntryMode(params) {
213
+ checkExtractionDeadline(params.deadline);
179
214
  const destinationPath = path.join(params.destinationRealDir, params.relPath);
180
215
  await assertResolvedInsideDestination({
181
216
  destinationRealDir: params.destinationRealDir,
182
217
  targetPath: destinationPath,
183
218
  originalPath: params.originalPath,
184
219
  });
220
+ checkExtractionDeadline(params.deadline);
185
221
  if (params.mode !== 0) {
186
222
  await chmodInsideDestinationBestEffort({
187
223
  destinationRealDir: params.destinationRealDir,
188
224
  destinationPath,
189
225
  mode: params.mode,
190
226
  originalPath: params.originalPath,
227
+ deadline: params.deadline,
191
228
  });
192
229
  }
230
+ checkExtractionDeadline(params.deadline);
193
231
  }
194
232
  async function assertExtractedFileHasNoHardlinkAlias(params) {
195
233
  const destinationPath = path.join(params.destinationRealDir, params.relPath);
@@ -296,6 +334,7 @@ export async function mergeExtractedTreeIntoDestination(params) {
296
334
  outPath: destinationPath,
297
335
  originalPath,
298
336
  isDirectory: true,
337
+ deadline: params.deadline,
299
338
  });
300
339
  await walk(sourcePath);
301
340
  await applyStagedEntryMode({
@@ -303,6 +342,7 @@ export async function mergeExtractedTreeIntoDestination(params) {
303
342
  relPath,
304
343
  mode: sourceStat.mode & 0o777,
305
344
  originalPath,
345
+ deadline: params.deadline,
306
346
  });
307
347
  continue;
308
348
  }
@@ -316,31 +356,39 @@ export async function mergeExtractedTreeIntoDestination(params) {
316
356
  outPath: destinationPath,
317
357
  originalPath,
318
358
  isDirectory: false,
359
+ deadline: params.deadline,
319
360
  });
320
- try {
321
- await targetRoot.copyIn(relPath, sourcePath, { mkdir: true });
322
- await assertExtractedFileHasNoHardlinkAlias({
323
- destinationRealDir: params.destinationRealDir,
324
- relPath,
325
- originalPath,
326
- });
327
- await applyStagedEntryMode({
328
- destinationRealDir: params.destinationRealDir,
329
- relPath,
330
- mode: sourceStat.mode & 0o777,
331
- originalPath,
332
- });
333
- }
334
- catch (err) {
335
- await removeExtractedDestinationFile({
336
- destinationRealDir: params.destinationRealDir,
337
- relPath,
338
- });
339
- if (err instanceof FsSafeError && (err.code === "hardlink" || err.code === "path-alias")) {
340
- throw symlinkTraversalError(originalPath);
361
+ await ownExtractionDestinationMutation(params.deadline, async () => {
362
+ try {
363
+ await targetRoot.copyIn(relPath, sourcePath, { mkdir: true });
364
+ checkExtractionDeadline(params.deadline);
365
+ await assertExtractedFileHasNoHardlinkAlias({
366
+ destinationRealDir: params.destinationRealDir,
367
+ relPath,
368
+ originalPath,
369
+ });
370
+ checkExtractionDeadline(params.deadline);
371
+ await applyStagedEntryMode({
372
+ destinationRealDir: params.destinationRealDir,
373
+ relPath,
374
+ mode: sourceStat.mode & 0o777,
375
+ originalPath,
376
+ deadline: params.deadline,
377
+ });
378
+ checkExtractionDeadline(params.deadline);
341
379
  }
342
- throw err;
343
- }
380
+ catch (err) {
381
+ await removeExtractedDestinationFile({
382
+ destinationRealDir: params.destinationRealDir,
383
+ relPath,
384
+ });
385
+ if (err instanceof FsSafeError &&
386
+ (err.code === "hardlink" || err.code === "path-alias")) {
387
+ throw symlinkTraversalError(originalPath);
388
+ }
389
+ throw err;
390
+ }
391
+ });
344
392
  }
345
393
  };
346
394
  await walk(params.sourceDir);
@@ -0,0 +1,7 @@
1
+ import type { TarEntryInfo } from "./archive-tar.js";
2
+ export declare function rawTarMember(header: Buffer, size: number, effectivePath?: string): TarEntryInfo;
3
+ export declare function createTarAdmissionPlan(manifest: readonly TarEntryInfo[], check: (entry: TarEntryInfo) => boolean, strip: number): {
4
+ consume(entry: TarEntryInfo): string | null;
5
+ finish(): void;
6
+ };
7
+ //# sourceMappingURL=archive-tar-admission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-tar-admission.d.ts","sourceRoot":"","sources":["../src/archive-tar-admission.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAYrD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,YAAY,CAS/F;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,YAAY,EAAE,EACjC,KAAK,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,OAAO,EACvC,KAAK,EAAE,MAAM,GACZ;IAAE,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,IAAI,IAAI,CAAA;CAAE,CAmBjE"}
@@ -0,0 +1,43 @@
1
+ import { ArchiveFormatError } from "./archive-errors.js";
2
+ import { stripArchivePath, validateArchiveEntryPath } from "./archive-entry.js";
3
+ import { readTarHeaderPaths } from "./archive-tar-header.js";
4
+ // node-tar's normalFsTypes/ReadEntry switch. All other non-metadata types
5
+ // bypass both its filter and entry event; they still belong to our manifest.
6
+ // Header.decode normalizes NUL to "0" before choosing the emitted File type.
7
+ const visibleTypes = new Map([
8
+ [0, "File"], [0x30, "File"], [0x31, "Link"], [0x32, "SymbolicLink"],
9
+ [0x33, "CharacterDevice"], [0x34, "BlockDevice"], [0x35, "Directory"],
10
+ [0x36, "FIFO"], [0x37, "ContiguousFile"], [0x44, "GNUDumpDir"],
11
+ ]);
12
+ export function rawTarMember(header, size, effectivePath) {
13
+ const { name, prefix } = readTarHeaderPaths(header);
14
+ validateArchiveEntryPath(name);
15
+ validateArchiveEntryPath(prefix);
16
+ const rawPath = prefix ? `${prefix}/${name}` : name;
17
+ validateArchiveEntryPath(rawPath);
18
+ const path = effectivePath ?? rawPath;
19
+ validateArchiveEntryPath(path);
20
+ return { path, size, type: visibleTypes.get(header[156]) ?? "Unsupported" };
21
+ }
22
+ export function createTarAdmissionPlan(manifest, check, strip) {
23
+ const visible = [];
24
+ for (const entry of manifest) {
25
+ const accepted = check(entry);
26
+ if (entry.type !== "Unsupported") {
27
+ visible.push({ entry, output: accepted ? stripArchivePath(entry.path, strip) : null });
28
+ }
29
+ }
30
+ let index = 0;
31
+ const mismatch = () => new ArchiveFormatError("invalid TAR header: parser disagrees with raw admission");
32
+ return {
33
+ consume(actual) {
34
+ const expected = visible[index++];
35
+ if (!expected || expected.entry.type !== actual.type || expected.entry.size !== actual.size ||
36
+ stripArchivePath(expected.entry.path, 0) !== stripArchivePath(actual.path, 0))
37
+ throw mismatch();
38
+ return expected.output;
39
+ },
40
+ finish() { if (index !== visible.length)
41
+ throw mismatch(); },
42
+ };
43
+ }
@@ -0,0 +1,2 @@
1
+ export declare function validateGnuMetadata(body: Buffer, type: "L" | "K"): string;
2
+ //# sourceMappingURL=archive-tar-gnu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-tar-gnu.d.ts","sourceRoot":"","sources":["../src/archive-tar-gnu.ts"],"names":[],"mappings":"AAKA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,GAAG,GAAG,MAAM,CAazE"}
@@ -0,0 +1,20 @@
1
+ import { ArchiveFormatError } from "./archive-errors.js";
2
+ import { validateArchiveEntryPath } from "./archive-entry.js";
3
+ // Keep the byte grammar aligned with native/src/tar_meter.rs. Parsers differ
4
+ // on embedded NULs and malformed UTF-8, so validate before either sees a name.
5
+ export function validateGnuMetadata(body, type) {
6
+ const value = body.at(-1) === 0 ? body.subarray(0, -1) : body;
7
+ if (!value.length || value.includes(0)) {
8
+ throw new ArchiveFormatError("invalid GNU metadata: empty name or embedded NUL");
9
+ }
10
+ let name;
11
+ try {
12
+ name = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(value);
13
+ }
14
+ catch {
15
+ throw new ArchiveFormatError("invalid GNU metadata: name is not valid UTF-8");
16
+ }
17
+ if (type === "L")
18
+ validateArchiveEntryPath(name);
19
+ return name;
20
+ }
@@ -0,0 +1,8 @@
1
+ export declare function validateTarChecksum(header: Buffer): void;
2
+ export declare function readTarHeaderPaths(header: Buffer): {
3
+ name: string;
4
+ prefix: string;
5
+ linkname: string;
6
+ };
7
+ export declare function validateTarHeader(header: Buffer): void;
8
+ //# sourceMappingURL=archive-tar-header.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-tar-header.d.ts","sourceRoot":"","sources":["../src/archive-tar-header.ts"],"names":[],"mappings":"AAGA,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAWxD;AAcD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CASrG;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAUtD"}
@@ -0,0 +1,47 @@
1
+ import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
2
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
3
+ export function validateTarChecksum(header) {
4
+ const field = header.subarray(148, 156);
5
+ const end = field.indexOf(0);
6
+ const digits = field.subarray(0, end < 0 ? field.length : end).toString("latin1").replace(/^ +| +$/g, "");
7
+ const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0);
8
+ // Require an in-field delimiter: node-tar otherwise reads into the typeflag.
9
+ if (!/^[0-7]+$/.test(digits) || ![0, 32].includes(field[7]) ||
10
+ (end >= 0 && field.subarray(end).some((byte) => byte !== 0 && byte !== 32)) ||
11
+ Number.parseInt(digits, 8) !== sum) {
12
+ throw new ArchiveFormatError("invalid TAR header: checksum failure");
13
+ }
14
+ }
15
+ function fixedField(field) {
16
+ const zero = field.indexOf(0);
17
+ if (zero >= 0 && field.subarray(zero).some((byte) => byte !== 0)) {
18
+ throw new ArchiveSecurityError("entry-path", "tar entry path contains bytes after NUL");
19
+ }
20
+ try {
21
+ return utf8.decode(field.subarray(0, zero < 0 ? field.length : zero));
22
+ }
23
+ catch {
24
+ throw new ArchiveSecurityError("entry-path", "tar entry path is not valid UTF-8");
25
+ }
26
+ }
27
+ export function readTarHeaderPaths(header) {
28
+ // node-tar's star layout uses a 130-byte prefix followed by atime/ctime.
29
+ const prefixEnd = header[475] === 0 ? 475 : 500;
30
+ return {
31
+ name: fixedField(header.subarray(0, 100)),
32
+ prefix: header.subarray(257, 265).equals(Buffer.from("ustar\0" + "00"))
33
+ ? fixedField(header.subarray(345, prefixEnd)) : "",
34
+ linkname: fixedField(header.subarray(157, 257)),
35
+ };
36
+ }
37
+ export function validateTarHeader(header) {
38
+ validateTarChecksum(header);
39
+ const { linkname } = readTarHeaderPaths(header);
40
+ const isLink = header[156] === 0x31 || header[156] === 0x32;
41
+ if (isLink && !linkname) {
42
+ throw new ArchiveFormatError("invalid TAR header: linkname required on a link header");
43
+ }
44
+ if (!isLink && linkname) {
45
+ throw new ArchiveFormatError("invalid TAR header: linkname forbidden on a non-link header");
46
+ }
47
+ }