@oh-my-pi/pi-utils 18.12.2 → 18.12.4
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 +16 -0
- package/README.md +1 -1
- package/dist/types/ar/codecs/gzip.d.ts +3 -0
- package/dist/types/ar/codecs/zstd.d.ts +3 -0
- package/dist/types/ar/limits.d.ts +2 -0
- package/dist/types/ar/source.d.ts +9 -0
- package/dist/types/ar/tar.d.ts +7 -2
- package/dist/types/dirs.d.ts +14 -2
- package/dist/types/env.d.ts +1 -2
- package/dist/types/test-runtime.d.ts +2 -0
- package/package.json +2 -2
- package/src/ar/codecs/gzip.ts +6 -0
- package/src/ar/codecs/zstd.ts +6 -0
- package/src/ar/deb.ts +1 -1
- package/src/ar/limits.ts +3 -0
- package/src/ar/registry.ts +59 -20
- package/src/ar/source.ts +40 -0
- package/src/ar/tar.ts +215 -53
- package/src/dirs.ts +34 -3
- package/src/env.ts +2 -9
- package/src/frontmatter.ts +11 -4
- package/src/test-runtime.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.12.4] - 2026-10-20
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `readTarEntriesFromBuffer()` in `@oh-my-pi/pi-utils/ar/tar` now returns `Promise<ArchiveIndexEntry[]>` instead of `ArchiveIndexEntry[]`; `await` its result ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `maxStreamSize` to `ArchiveLimits` (default 4 GB): the most a large `.tar.gz` or `.tar.zst` may decompress in one pass, so a decompression bomb fails instead of running without bound ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed `bun test` runs writing their logs into your real `~/.omp/logs`: test processes now log to a per-user `omp-test-logs-<uid>/` under the OS temp dir ([#16386](https://github.com/can1357/oh-my-pi/pull/16386) by [@will-bogusz](https://github.com/will-bogusz))
|
|
18
|
+
- Fixed `.tar.gz` and `.tar.zst` archives that decompress past 256 MB failing to list or read with a raw `Cannot create a Buffer larger than …` error; a bare `.gz` or `.zst` that large now names the archive size limit ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
|
|
19
|
+
- Fixed frontmatter repair deleting HTML comments from document bodies and plain markdown; it now strips only leading comments ahead of the opening delimiter, so prompt bodies keep their comments ([#16481](https://github.com/can1357/oh-my-pi/pull/16481) by [@adi-IL](https://github.com/adi-IL)).
|
|
20
|
+
|
|
5
21
|
## [18.12.2] - 2026-10-18
|
|
6
22
|
|
|
7
23
|
### Added
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Shared utilities for [omp](https://github.com/can1357/oh-my-pi) packages. Zero c
|
|
|
6
6
|
|
|
7
7
|
| Module | Purpose |
|
|
8
8
|
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
9
|
-
| `logger` | Centralized logger writing to `~/.omp/logs/`
|
|
9
|
+
| `logger` | Centralized logger writing to `~/.omp/logs/` (`bun test`: temp dir), rotating, never stdout |
|
|
10
10
|
| `prompt` | Handlebars-based prompt templating and formatting helpers |
|
|
11
11
|
| `dirs` | Path helpers for omp config directories (`~/.omp`, XDG-aware on Linux) |
|
|
12
12
|
| `stream` | `readStream` / `readLines` helpers over `ReadableStream` |
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type ByteSource } from "../source.js";
|
|
1
2
|
/** gzip magic: 0x1f 0x8b. */
|
|
2
3
|
export declare function isGzip(bytes: Uint8Array): boolean;
|
|
3
4
|
/** Decompress one gzip stream, bounded so a gzip bomb cannot inflate without limit. */
|
|
4
5
|
export declare function gzipDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
|
|
6
|
+
/** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
|
|
7
|
+
export declare function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
|
|
5
8
|
/** Compress bytes as one gzip stream (tar.gz writing). */
|
|
6
9
|
export declare function gzipCompress(bytes: Uint8Array): Promise<Uint8Array>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type ByteSource } from "../source.js";
|
|
1
2
|
/** zstd frame magic: 28 b5 2f fd. */
|
|
2
3
|
export declare function isZstd(bytes: Uint8Array): boolean;
|
|
3
4
|
/** Decompress one zstd frame, bounded to `maxOutput` bytes. */
|
|
4
5
|
export declare function zstdDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
|
|
6
|
+
/** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
|
|
7
|
+
export declare function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
|
|
5
8
|
/** Compress bytes as one zstd frame (tar.zst writing). */
|
|
6
9
|
export declare function zstdCompress(bytes: Uint8Array): Promise<Uint8Array>;
|
|
@@ -9,6 +9,8 @@ export interface ArchiveLimits {
|
|
|
9
9
|
maxEntries: number;
|
|
10
10
|
/** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
|
|
11
11
|
maxInMemorySize: number;
|
|
12
|
+
/** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
|
|
13
|
+
maxStreamSize: number;
|
|
12
14
|
/** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
|
|
13
15
|
maxIndexSize: number;
|
|
14
16
|
/** Max declared bytes for a single extracted member. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Transform } from "node:stream";
|
|
1
2
|
/**
|
|
2
3
|
* A byte window into an archive — file-backed (lazy, ranged reads) or
|
|
3
4
|
* in-memory. Format readers index through this so ZIP/ASAR/RAR payloads are
|
|
@@ -17,6 +18,14 @@ export declare function memoryByteSource(buffer: Uint8Array): ByteSource;
|
|
|
17
18
|
export declare function fileByteSource(filePath: string): ByteSource;
|
|
18
19
|
/** Materialize an entire {@link ByteSource}; use only under a limits check. */
|
|
19
20
|
export declare function readAllBytes(source: ByteSource): Promise<Uint8Array>;
|
|
21
|
+
/** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
|
|
22
|
+
export declare const STREAM_CHUNK_SIZE: number;
|
|
23
|
+
/**
|
|
24
|
+
* Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
|
|
25
|
+
* the compressed nor the decompressed stream is ever held whole. Output past
|
|
26
|
+
* `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
|
|
27
|
+
*/
|
|
28
|
+
export declare function decodeByteSource(source: ByteSource, decoder: Transform, maxOutput: number): AsyncGenerator<Uint8Array>;
|
|
20
29
|
/** Options for {@link httpByteSource}. */
|
|
21
30
|
export interface HttpByteSourceOptions {
|
|
22
31
|
/** Extra request headers (e.g. authorization). */
|
package/dist/types/ar/tar.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types.js";
|
|
2
|
-
/** Index an already-decompressed tar buffer
|
|
3
|
-
export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[]
|
|
2
|
+
/** Index an already-decompressed tar buffer; members are views into it. */
|
|
3
|
+
export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
|
|
4
|
+
/**
|
|
5
|
+
* Index a decompressed tar stream that is too large to hold in memory: one
|
|
6
|
+
* pass over `open()` walks the headers, and member reads open it again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
|
|
4
9
|
/** Read and index a tar source after one bounded whole-stream read. */
|
|
5
10
|
export declare const readTar: FormatReader;
|
|
6
11
|
/** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -44,7 +44,14 @@ export declare function normalizeProfileName(profile: string | undefined): strin
|
|
|
44
44
|
export declare function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined;
|
|
45
45
|
/** Profile-independent config root (~/.omp), shared by every omp profile. */
|
|
46
46
|
export declare function getBaseConfigRoot(): string;
|
|
47
|
-
export
|
|
47
|
+
export interface EquivalentPathOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
|
|
50
|
+
* missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
|
|
51
|
+
*/
|
|
52
|
+
throughMissing?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string;
|
|
48
55
|
export declare function normalizePathForComparison(inputPath: string): string;
|
|
49
56
|
/**
|
|
50
57
|
* Compare paths already normalized by {@link normalizePathForComparison}.
|
|
@@ -135,7 +142,12 @@ export declare function getAgentDir(): string;
|
|
|
135
142
|
export declare function getProjectAgentDir(cwd?: string): string;
|
|
136
143
|
/** Get the reports directory (~/.omp/reports). */
|
|
137
144
|
export declare function getReportsDir(): string;
|
|
138
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Get the logs directory (~/.omp/logs). `bun test` processes get
|
|
147
|
+
* `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
|
|
148
|
+
* developer's real logs; the uid suffix (as for the test session-owners dir)
|
|
149
|
+
* keeps users of a shared `/tmp` from owning each other's directory.
|
|
150
|
+
*/
|
|
139
151
|
export declare function getLogsDir(): string;
|
|
140
152
|
/**
|
|
141
153
|
* Local-timezone `YYYY-MM-DD` day key (zero-padded), formatted exactly like
|
package/dist/types/env.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./test-runtime.js";
|
|
1
2
|
export * from "./worker-host.js";
|
|
2
3
|
/**
|
|
3
4
|
* Strict shell-identifier shape. Used for dotenv keys we accept into
|
|
@@ -82,8 +83,6 @@ export declare function $envExact(name: string, env?: Record<string, string | un
|
|
|
82
83
|
* Empty, invalid, NaN, zero, or negative values return `defaultValue`.
|
|
83
84
|
*/
|
|
84
85
|
export declare function $envpos(name: string, defaultValue: number): number;
|
|
85
|
-
/** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
|
|
86
|
-
export declare function isBunTestRuntime(): boolean;
|
|
87
86
|
/**
|
|
88
87
|
* True when real-terminal side effects must be suppressed: stdout escape/frame
|
|
89
88
|
* writes, stdin raw-mode + resume, CSI/OSC capability probes, SIGWINCH, window
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.12.
|
|
3
|
+
"version": "18.12.4",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.12.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.12.4"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/ar/codecs/gzip.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promisify } from "node:util";
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
3
|
import { ArchiveError } from "../error";
|
|
4
|
+
import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
|
|
4
5
|
|
|
5
6
|
const gzipAsync = promisify(zlib.gzip);
|
|
6
7
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
@@ -19,6 +20,11 @@ export async function gzipDecompress(bytes: Uint8Array, maxOutput: number): Prom
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
|
|
24
|
+
export function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
|
|
25
|
+
return decodeByteSource(source, zlib.createGunzip({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/** Compress bytes as one gzip stream (tar.gz writing). */
|
|
23
29
|
export function gzipCompress(bytes: Uint8Array): Promise<Uint8Array> {
|
|
24
30
|
return gzipAsync(bytes);
|
package/src/ar/codecs/zstd.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promisify } from "node:util";
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
3
|
import { ArchiveError } from "../error";
|
|
4
|
+
import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
|
|
4
5
|
|
|
5
6
|
const zstdCompressAsync = promisify(zlib.zstdCompress);
|
|
6
7
|
const zstdDecompressAsync = promisify(zlib.zstdDecompress);
|
|
@@ -19,6 +20,11 @@ export async function zstdDecompress(bytes: Uint8Array, maxOutput: number): Prom
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
|
|
24
|
+
export function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
|
|
25
|
+
return decodeByteSource(source, zlib.createZstdDecompress({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/** Compress bytes as one zstd frame (tar.zst writing). */
|
|
23
29
|
export function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
|
|
24
30
|
return zstdCompressAsync(bytes);
|
package/src/ar/deb.ts
CHANGED
|
@@ -135,7 +135,7 @@ async function readDebImpl(source: ByteSource, options: FormatReadOptions): Prom
|
|
|
135
135
|
}
|
|
136
136
|
const compressed = await readOuterMember(outerEntry);
|
|
137
137
|
const tarBytes = await decompressDebTar(compressed, tar.compression, options);
|
|
138
|
-
const innerEntries = readTarEntriesFromBuffer(tarBytes, options);
|
|
138
|
+
const innerEntries = await readTarEntriesFromBuffer(tarBytes, options);
|
|
139
139
|
for (const innerEntry of innerEntries) {
|
|
140
140
|
upsertArchiveEntry(result, tar.kind === "control" ? prefixControlEntry(innerEntry) : innerEntry);
|
|
141
141
|
}
|
package/src/ar/limits.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface ArchiveLimits {
|
|
|
13
13
|
maxEntries: number;
|
|
14
14
|
/** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
|
|
15
15
|
maxInMemorySize: number;
|
|
16
|
+
/** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
|
|
17
|
+
maxStreamSize: number;
|
|
16
18
|
/** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
|
|
17
19
|
maxIndexSize: number;
|
|
18
20
|
/** Max declared bytes for a single extracted member. */
|
|
@@ -26,6 +28,7 @@ export interface ArchiveLimits {
|
|
|
26
28
|
export const DEFAULT_ARCHIVE_LIMITS: ArchiveLimits = {
|
|
27
29
|
maxEntries: 1_000_000,
|
|
28
30
|
maxInMemorySize: 256 * 1024 * 1024,
|
|
31
|
+
maxStreamSize: 4 * 1024 * 1024 * 1024,
|
|
29
32
|
maxIndexSize: 64 * 1024 * 1024,
|
|
30
33
|
maxMemberSize: 64 * 1024 * 1024,
|
|
31
34
|
maxPathBytes: 4096,
|
package/src/ar/registry.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { formatBytes } from "../format";
|
|
2
3
|
import { readArj, sniffArj } from "./arj";
|
|
3
4
|
import { readAsar, sniffAsar } from "./asar";
|
|
4
5
|
import { readUInt32LE } from "./bytes";
|
|
5
6
|
import { readCab, sniffCab } from "./cab";
|
|
6
7
|
import { bzip2Decompress, isBzip2 } from "./codecs/bzip2";
|
|
7
|
-
import {
|
|
8
|
+
import { gzipDecompressStream, isGzip } from "./codecs/gzip";
|
|
8
9
|
import { lzmaAloneDecompress } from "./codecs/lzma";
|
|
9
10
|
import { isCompressZ, lzwDecompress } from "./codecs/lzw";
|
|
10
11
|
import { isXz, xzDecompress } from "./codecs/xz";
|
|
11
|
-
import { isZstd,
|
|
12
|
+
import { isZstd, zstdDecompressStream } from "./codecs/zstd";
|
|
12
13
|
import { readCpio, sniffCpio } from "./cpio";
|
|
13
14
|
import { readDeb, sniffDeb } from "./deb";
|
|
15
|
+
import { ArchiveError } from "./error";
|
|
14
16
|
import { readIso, sniffIso } from "./iso";
|
|
15
17
|
import { assertInMemorySize } from "./limits";
|
|
16
18
|
import { readLzh, sniffLzh } from "./lzh";
|
|
17
19
|
import { readRar, sniffRar } from "./rar";
|
|
18
20
|
import { readRpm, sniffRpm } from "./rpm";
|
|
19
21
|
import { readSevenZip, sniffSevenZip } from "./sevenzip";
|
|
20
|
-
import { readAllBytes } from "./source";
|
|
21
|
-
import { readTar, readTarEntriesFromBuffer, sniffTar } from "./tar";
|
|
22
|
-
import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, MemberSource } from "./types";
|
|
22
|
+
import { type ByteSource, readAllBytes } from "./source";
|
|
23
|
+
import { readTar, readTarEntriesFromBuffer, readTarEntriesFromStream, sniffTar } from "./tar";
|
|
24
|
+
import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
|
|
23
25
|
import { readUnixAr, sniffUnixAr } from "./unix-ar";
|
|
24
26
|
import { readZip, sniffZip } from "./zip";
|
|
25
27
|
|
|
@@ -100,6 +102,21 @@ class BufferMember implements MemberSource {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/** Index decompressed bytes as tar when they are one, else as a single stem-named member. */
|
|
106
|
+
async function readDecompressed(inner: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
|
|
107
|
+
assertInMemorySize(inner.byteLength, options.limits);
|
|
108
|
+
if (sniffTar(inner)) {
|
|
109
|
+
return readTarEntriesFromBuffer(inner, options);
|
|
110
|
+
}
|
|
111
|
+
const entry: ArchiveIndexEntry = {
|
|
112
|
+
path: stemMemberName(options.archivePath),
|
|
113
|
+
isDirectory: false,
|
|
114
|
+
size: inner.byteLength,
|
|
115
|
+
storage: { type: "member", source: new BufferMember(inner) },
|
|
116
|
+
};
|
|
117
|
+
return [entry];
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
/**
|
|
104
121
|
* Reader for one compressed stream: decompress bounded, then index the inner
|
|
105
122
|
* bytes as tar when they are one, else surface a single stem-named member.
|
|
@@ -109,28 +126,50 @@ class BufferMember implements MemberSource {
|
|
|
109
126
|
function compressedReader(decompress: (bytes: Uint8Array, maxOutput: number) => Promise<Uint8Array>): FormatReader {
|
|
110
127
|
return async (source, options) => {
|
|
111
128
|
assertInMemorySize(source.size, options.limits);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
return readDecompressed(await decompress(await readAllBytes(source), options.limits.maxInMemorySize), options);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* {@link compressedReader} for codecs that decompress as a stream. Output that
|
|
135
|
+
* fits `maxInMemorySize` is indexed the same way; a tar that inflates past it
|
|
136
|
+
* is indexed by a streaming header walk instead, its members decompressed
|
|
137
|
+
* again when read, so memory stays bounded by the limits either way. Each pass
|
|
138
|
+
* stops at `maxStreamSize`.
|
|
139
|
+
*/
|
|
140
|
+
function streamedReader(
|
|
141
|
+
decompress: (source: ByteSource, maxOutput: number) => AsyncIterable<Uint8Array>,
|
|
142
|
+
): FormatReader {
|
|
143
|
+
return async (source, options) => {
|
|
144
|
+
const { maxInMemorySize, maxStreamSize } = options.limits;
|
|
145
|
+
const chunks: Uint8Array[] = [];
|
|
146
|
+
let size = 0;
|
|
147
|
+
for await (const chunk of decompress(source, maxStreamSize)) {
|
|
148
|
+
chunks.push(chunk);
|
|
149
|
+
size += chunk.byteLength;
|
|
150
|
+
if (size > maxInMemorySize) break;
|
|
151
|
+
}
|
|
152
|
+
// Copy out what is needed, then drop the chunks so they are not held alongside it.
|
|
153
|
+
const kept = Buffer.concat(chunks, size <= maxInMemorySize ? size : Math.min(size, 512));
|
|
154
|
+
chunks.length = 0;
|
|
155
|
+
if (size <= maxInMemorySize) return readDecompressed(kept, options);
|
|
156
|
+
// Past the limit only a tar has headers to walk; its first 512-byte block tells.
|
|
157
|
+
if (!sniffTar(kept)) {
|
|
158
|
+
throw new ArchiveError(
|
|
159
|
+
`Archive is too large to read in memory (decompressed > ${formatBytes(maxInMemorySize)} limit)`,
|
|
160
|
+
);
|
|
116
161
|
}
|
|
117
|
-
|
|
118
|
-
path: stemMemberName(options.archivePath),
|
|
119
|
-
isDirectory: false,
|
|
120
|
-
size: inner.byteLength,
|
|
121
|
-
storage: { type: "member", source: new BufferMember(inner) },
|
|
122
|
-
};
|
|
123
|
-
return [entry];
|
|
162
|
+
return readTarEntriesFromStream(() => decompress(source, maxStreamSize), options);
|
|
124
163
|
};
|
|
125
164
|
}
|
|
126
165
|
|
|
127
166
|
const READERS: Record<ArchiveFormat, FormatReader> = {
|
|
128
167
|
zip: readZip,
|
|
129
168
|
tar: readTar,
|
|
130
|
-
"tar.gz":
|
|
169
|
+
"tar.gz": streamedReader(gzipDecompressStream),
|
|
131
170
|
"tar.bz2": compressedReader(bzip2Decompress),
|
|
132
171
|
"tar.xz": compressedReader(xzDecompress),
|
|
133
|
-
"tar.zst":
|
|
172
|
+
"tar.zst": streamedReader(zstdDecompressStream),
|
|
134
173
|
"tar.Z": compressedReader(lzwDecompress),
|
|
135
174
|
asar: readAsar,
|
|
136
175
|
rar: readRar,
|
|
@@ -143,10 +182,10 @@ const READERS: Record<ArchiveFormat, FormatReader> = {
|
|
|
143
182
|
deb: readDeb,
|
|
144
183
|
lzh: readLzh,
|
|
145
184
|
arj: readArj,
|
|
146
|
-
gz:
|
|
185
|
+
gz: streamedReader(gzipDecompressStream),
|
|
147
186
|
bz2: compressedReader(bzip2Decompress),
|
|
148
187
|
xz: compressedReader(xzDecompress),
|
|
149
|
-
zst:
|
|
188
|
+
zst: streamedReader(zstdDecompressStream),
|
|
150
189
|
Z: compressedReader(lzwDecompress),
|
|
151
190
|
lzma: compressedReader(lzmaAloneDecompress),
|
|
152
191
|
};
|
package/src/ar/source.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Readable, type Transform } from "node:stream";
|
|
2
|
+
import { formatBytes } from "../format";
|
|
1
3
|
import { LRUCache } from "../lru";
|
|
2
4
|
import { ArchiveError } from "./error";
|
|
3
5
|
|
|
@@ -62,6 +64,44 @@ export async function readAllBytes(source: ByteSource): Promise<Uint8Array> {
|
|
|
62
64
|
return source.read(0, source.size);
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
|
|
68
|
+
export const STREAM_CHUNK_SIZE = 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
async function* readChunks(source: ByteSource): AsyncGenerator<Uint8Array> {
|
|
71
|
+
for (let start = 0; start < source.size; start += STREAM_CHUNK_SIZE) {
|
|
72
|
+
yield await source.read(start, Math.min(source.size, start + STREAM_CHUNK_SIZE));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
|
|
78
|
+
* the compressed nor the decompressed stream is ever held whole. Output past
|
|
79
|
+
* `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
|
|
80
|
+
*/
|
|
81
|
+
export async function* decodeByteSource(
|
|
82
|
+
source: ByteSource,
|
|
83
|
+
decoder: Transform,
|
|
84
|
+
maxOutput: number,
|
|
85
|
+
): AsyncGenerator<Uint8Array> {
|
|
86
|
+
const input = Readable.from(readChunks(source));
|
|
87
|
+
input.once("error", error => decoder.destroy(error));
|
|
88
|
+
let output = 0;
|
|
89
|
+
try {
|
|
90
|
+
for await (const chunk of input.pipe(decoder)) {
|
|
91
|
+
output += chunk.byteLength;
|
|
92
|
+
if (output > maxOutput) {
|
|
93
|
+
throw new ArchiveError(`Archive decompresses past the ${formatBytes(maxOutput)} stream limit`);
|
|
94
|
+
}
|
|
95
|
+
yield chunk;
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof ArchiveError) throw error;
|
|
99
|
+
throw new ArchiveError(error instanceof Error ? error.message : String(error));
|
|
100
|
+
} finally {
|
|
101
|
+
input.destroy();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/** Options for {@link httpByteSource}. */
|
|
66
106
|
export interface HttpByteSourceOptions {
|
|
67
107
|
/** Extra request headers (e.g. authorization). */
|
package/src/ar/tar.ts
CHANGED
|
@@ -53,13 +53,141 @@ interface PendingTarLink {
|
|
|
53
53
|
targetPath: string;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
|
|
57
|
+
class TarByteStream {
|
|
58
|
+
readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
|
|
59
|
+
#chunk: Uint8Array = new Uint8Array(0);
|
|
60
|
+
#position = 0;
|
|
61
|
+
|
|
62
|
+
constructor(chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) {
|
|
63
|
+
this.#chunks = Symbol.asyncIterator in chunks ? chunks[Symbol.asyncIterator]() : chunks[Symbol.iterator]();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bytes consumed so far. */
|
|
67
|
+
get position(): number {
|
|
68
|
+
return this.#position;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The next `length` bytes; shorter only when the stream ends first. */
|
|
72
|
+
async read(length: number): Promise<Uint8Array> {
|
|
73
|
+
if (this.#chunk.byteLength >= length) {
|
|
74
|
+
const bytes = this.#chunk.subarray(0, length);
|
|
75
|
+
this.#chunk = this.#chunk.subarray(length);
|
|
76
|
+
this.#position += length;
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
const bytes = new Uint8Array(length);
|
|
80
|
+
let filled = 0;
|
|
81
|
+
while (filled < length && (this.#chunk.byteLength > 0 || (await this.#next()))) {
|
|
82
|
+
const take = Math.min(length - filled, this.#chunk.byteLength);
|
|
83
|
+
bytes.set(this.#chunk.subarray(0, take), filled);
|
|
84
|
+
this.#chunk = this.#chunk.subarray(take);
|
|
85
|
+
filled += take;
|
|
86
|
+
}
|
|
87
|
+
this.#position += filled;
|
|
88
|
+
return filled === length ? bytes : bytes.subarray(0, filled);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Step over `length` bytes; false when the stream ends first. */
|
|
92
|
+
async skip(length: number): Promise<boolean> {
|
|
93
|
+
let remaining = length;
|
|
94
|
+
while (remaining > 0) {
|
|
95
|
+
if (this.#chunk.byteLength === 0 && !(await this.#next())) return false;
|
|
96
|
+
const take = Math.min(remaining, this.#chunk.byteLength);
|
|
97
|
+
this.#chunk = this.#chunk.subarray(take);
|
|
98
|
+
this.#position += take;
|
|
99
|
+
remaining -= take;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stop the underlying stream without draining it. */
|
|
105
|
+
async close(): Promise<void> {
|
|
106
|
+
await this.#chunks.return?.();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async #next(): Promise<boolean> {
|
|
110
|
+
const result = await this.#chunks.next();
|
|
111
|
+
if (result.done) return false;
|
|
112
|
+
this.#chunk = result.value;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where an indexed tar's member bytes come from, by offset into the tar stream. */
|
|
118
|
+
interface TarPayload {
|
|
119
|
+
read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class TarBufferPayload implements TarPayload {
|
|
57
123
|
readonly #buffer: Uint8Array;
|
|
124
|
+
|
|
125
|
+
constructor(buffer: Uint8Array) {
|
|
126
|
+
this.#buffer = buffer;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
|
|
130
|
+
if (size > this.#buffer.byteLength - offset) {
|
|
131
|
+
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
|
|
132
|
+
}
|
|
133
|
+
const bytes = this.#buffer.subarray(offset, offset + size);
|
|
134
|
+
if (bytes.byteLength !== size) {
|
|
135
|
+
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
|
|
136
|
+
}
|
|
137
|
+
return bytes;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Member bytes of a tar too large to hold decompressed: each read decompresses
|
|
143
|
+
* the stream again up to the member. Reads are serialized, and a read ahead of
|
|
144
|
+
* the previous one continues that pass, so reading every member in archive
|
|
145
|
+
* order decompresses the stream once.
|
|
146
|
+
*/
|
|
147
|
+
class TarStreamPayload implements TarPayload {
|
|
148
|
+
readonly #open: () => AsyncIterable<Uint8Array>;
|
|
149
|
+
#stream: TarByteStream | undefined;
|
|
150
|
+
#pending: Promise<unknown> = Promise.resolve();
|
|
151
|
+
|
|
152
|
+
constructor(open: () => AsyncIterable<Uint8Array>) {
|
|
153
|
+
this.#open = open;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
|
|
157
|
+
const bytes = this.#pending.then(() => this.#readAt(offset, size, memberPath));
|
|
158
|
+
this.#pending = bytes.catch(() => undefined);
|
|
159
|
+
return bytes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #readAt(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
|
|
163
|
+
let stream = this.#stream;
|
|
164
|
+
this.#stream = undefined;
|
|
165
|
+
if (!stream || stream.position > offset) {
|
|
166
|
+
await stream?.close();
|
|
167
|
+
stream = new TarByteStream(this.#open());
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const bytes = (await stream.skip(offset - stream.position)) ? await stream.read(size) : undefined;
|
|
171
|
+
if (bytes?.byteLength !== size) {
|
|
172
|
+
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
|
|
173
|
+
}
|
|
174
|
+
this.#stream = stream;
|
|
175
|
+
// A view into a decoder chunk would keep the whole chunk alive with the member.
|
|
176
|
+
return bytes.byteLength === bytes.buffer.byteLength ? bytes : new Uint8Array(bytes);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await stream.close();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class TarMemberSource implements MemberSource {
|
|
185
|
+
readonly #payload: TarPayload;
|
|
58
186
|
readonly #dataOffset: number;
|
|
59
187
|
readonly #sparse: boolean;
|
|
60
188
|
|
|
61
|
-
constructor(
|
|
62
|
-
this.#
|
|
189
|
+
constructor(payload: TarPayload, dataOffset: number, sparse: boolean) {
|
|
190
|
+
this.#payload = payload;
|
|
63
191
|
this.#dataOffset = dataOffset;
|
|
64
192
|
this.#sparse = sparse;
|
|
65
193
|
}
|
|
@@ -70,14 +198,7 @@ class TarMemberSource implements MemberSource {
|
|
|
70
198
|
`Archive member '${formatArchivePathForError(memberPath)}' is a sparse file and cannot be read`,
|
|
71
199
|
);
|
|
72
200
|
}
|
|
73
|
-
|
|
74
|
-
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
|
|
75
|
-
}
|
|
76
|
-
const bytes = this.#buffer.subarray(this.#dataOffset, this.#dataOffset + size);
|
|
77
|
-
if (bytes.byteLength !== size) {
|
|
78
|
-
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
|
|
79
|
-
}
|
|
80
|
-
return bytes;
|
|
201
|
+
return this.#payload.read(this.#dataOffset, size, memberPath);
|
|
81
202
|
}
|
|
82
203
|
}
|
|
83
204
|
|
|
@@ -172,7 +293,6 @@ function checksumMatches(buffer: Uint8Array, offset: number): boolean {
|
|
|
172
293
|
}
|
|
173
294
|
|
|
174
295
|
function parsePaxRecords(data: Uint8Array, limits: ArchiveLimits): Map<string, string> {
|
|
175
|
-
assertIndexSize(data.byteLength, limits, "tar PAX metadata");
|
|
176
296
|
const attrs = new Map<string, string>();
|
|
177
297
|
let pos = 0;
|
|
178
298
|
while (pos < data.byteLength) {
|
|
@@ -295,7 +415,6 @@ function applyOldGnuNameRecords(
|
|
|
295
415
|
pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
|
|
296
416
|
limits: ArchiveLimits,
|
|
297
417
|
): void {
|
|
298
|
-
assertIndexSize(data.byteLength, limits, "old-GNU name metadata");
|
|
299
418
|
const terminator = data.indexOf(0);
|
|
300
419
|
const end = terminator === -1 ? data.byteLength : terminator;
|
|
301
420
|
let start = 0;
|
|
@@ -412,10 +531,31 @@ function resolvePendingLinks(
|
|
|
412
531
|
if (unresolved.size > 0) throw new ArchiveError("Archive contains cyclic or unsupported links");
|
|
413
532
|
}
|
|
414
533
|
|
|
415
|
-
/**
|
|
416
|
-
|
|
534
|
+
/** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
|
|
535
|
+
async function readTarMetadata(
|
|
536
|
+
stream: TarByteStream,
|
|
537
|
+
size: number,
|
|
538
|
+
limits: ArchiveLimits,
|
|
539
|
+
what: string,
|
|
540
|
+
): Promise<Uint8Array> {
|
|
541
|
+
assertIndexSize(size, limits, what);
|
|
542
|
+
const data = await stream.read(size);
|
|
543
|
+
if (data.byteLength !== size || !(await stream.skip(paddedSize(size) - size))) {
|
|
544
|
+
throw new ArchiveError("Archive member data is truncated");
|
|
545
|
+
}
|
|
546
|
+
return data;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Walk tar headers with bounded GNU, ustar, and PAX handling. Member data is
|
|
551
|
+
* stepped over, never held: entries point into `payload` by stream offset.
|
|
552
|
+
*/
|
|
553
|
+
async function readTarEntries(
|
|
554
|
+
stream: TarByteStream,
|
|
555
|
+
payload: TarPayload,
|
|
556
|
+
options: FormatReadOptions,
|
|
557
|
+
): Promise<ArchiveIndexEntry[]> {
|
|
417
558
|
const { limits } = options;
|
|
418
|
-
assertInMemorySize(buffer.byteLength, limits);
|
|
419
559
|
const entries = new Map<string, ArchiveIndexEntry>();
|
|
420
560
|
const pendingLinks = new Map<ArchiveIndexEntry, PendingTarLink>();
|
|
421
561
|
const addEntry = (entry: ArchiveIndexEntry, pendingLink?: PendingTarLink): void => {
|
|
@@ -426,61 +566,58 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
|
|
|
426
566
|
if (pendingLink) pendingLinks.set(indexed, pendingLink);
|
|
427
567
|
assertEntryCount(entries.size, limits);
|
|
428
568
|
};
|
|
429
|
-
let offset = 0;
|
|
430
569
|
let longName: string | undefined;
|
|
431
570
|
let longLink: string | undefined;
|
|
432
571
|
let localPax: Map<string, string> | undefined;
|
|
433
572
|
const globalPax = new Map<string, string>();
|
|
434
573
|
let sawTerminator = false;
|
|
435
574
|
|
|
436
|
-
|
|
437
|
-
|
|
575
|
+
for (
|
|
576
|
+
let header = await stream.read(BLOCK_SIZE);
|
|
577
|
+
header.byteLength === BLOCK_SIZE;
|
|
578
|
+
header = await stream.read(BLOCK_SIZE)
|
|
579
|
+
) {
|
|
580
|
+
if (isZeroBlock(header, 0)) {
|
|
438
581
|
sawTerminator = true;
|
|
439
582
|
break;
|
|
440
583
|
}
|
|
441
|
-
if (!checksumMatches(
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
let
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const prefix = readTarString(buffer, headerOffset + PREFIX_OFFSET, PREFIX_LENGTH);
|
|
584
|
+
if (!checksumMatches(header, 0)) throw new ArchiveError("Invalid or corrupt tar archive header");
|
|
585
|
+
const typeFlag = String.fromCharCode(header[TYPEFLAG_OFFSET] || 0x30);
|
|
586
|
+
let size = readTarSize(header, SIZE_OFFSET);
|
|
587
|
+
let name = readTarString(header, NAME_OFFSET, NAME_LENGTH);
|
|
588
|
+
if (isUstarHeader(header, 0)) {
|
|
589
|
+
const prefix = readTarString(header, PREFIX_OFFSET, PREFIX_LENGTH);
|
|
448
590
|
if (prefix) name = `${prefix}/${name}`;
|
|
449
591
|
}
|
|
450
|
-
let linkName = readTarString(
|
|
451
|
-
const mtime = readTarNumeric(
|
|
452
|
-
const rawMode = readTarNumeric(
|
|
592
|
+
let linkName = readTarString(header, LINKNAME_OFFSET, LINKNAME_LENGTH);
|
|
593
|
+
const mtime = readTarNumeric(header, MTIME_OFFSET, MTIME_LENGTH);
|
|
594
|
+
const rawMode = readTarNumeric(header, MODE_OFFSET, MODE_LENGTH);
|
|
453
595
|
const mode = Number.isSafeInteger(rawMode) && rawMode >= 0 ? rawMode : undefined;
|
|
454
|
-
offset += BLOCK_SIZE;
|
|
455
|
-
const dataBlocks = paddedSize(size);
|
|
456
|
-
if (dataBlocks > buffer.byteLength - offset) throw new ArchiveError("Archive member data is truncated");
|
|
457
|
-
const data = buffer.subarray(offset, offset + size);
|
|
458
596
|
|
|
459
597
|
if (typeFlag === "L") {
|
|
460
|
-
|
|
598
|
+
const data = await readTarMetadata(stream, size, limits, "GNU long-name metadata");
|
|
461
599
|
longName = readMetadataPath(data, "GNU long path", limits);
|
|
462
|
-
offset += dataBlocks;
|
|
463
600
|
continue;
|
|
464
601
|
}
|
|
465
602
|
if (typeFlag === "K") {
|
|
466
|
-
|
|
603
|
+
const data = await readTarMetadata(stream, size, limits, "GNU long-link metadata");
|
|
467
604
|
longLink = readMetadataPath(data, "GNU long link target", limits);
|
|
468
|
-
offset += dataBlocks;
|
|
469
605
|
continue;
|
|
470
606
|
}
|
|
471
607
|
if (typeFlag === "N") {
|
|
608
|
+
const data = await readTarMetadata(stream, size, limits, "old-GNU name metadata");
|
|
472
609
|
applyOldGnuNameRecords(data, entries, pendingLinks, limits);
|
|
473
|
-
offset += dataBlocks;
|
|
474
610
|
continue;
|
|
475
611
|
}
|
|
476
612
|
if (typeFlag === "x" || typeFlag === "X") {
|
|
477
|
-
localPax = parsePaxRecords(
|
|
478
|
-
offset += dataBlocks;
|
|
613
|
+
localPax = parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits);
|
|
479
614
|
continue;
|
|
480
615
|
}
|
|
481
616
|
if (typeFlag === "g") {
|
|
482
|
-
applyGlobalPax(
|
|
483
|
-
|
|
617
|
+
applyGlobalPax(
|
|
618
|
+
globalPax,
|
|
619
|
+
parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits),
|
|
620
|
+
);
|
|
484
621
|
continue;
|
|
485
622
|
}
|
|
486
623
|
if (typeFlag === "M") throw new ArchiveError("GNU multi-volume tar members are not supported");
|
|
@@ -499,22 +636,18 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
|
|
|
499
636
|
const paxSparseRealSize = paxAttribute(globalPax, localPax, "GNU.sparse.realsize");
|
|
500
637
|
if (paxSparseRealSize !== undefined) displaySize = parsePaxSize(paxSparseRealSize, "sparse real size");
|
|
501
638
|
const sparse = typeFlag === "S" || paxDeclaresSparse(globalPax, localPax);
|
|
502
|
-
if (typeFlag === "S" &&
|
|
639
|
+
if (typeFlag === "S" && header[GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
|
|
503
640
|
let extended = true;
|
|
504
641
|
while (extended) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
extended = buffer[offset + GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
|
|
509
|
-
offset += BLOCK_SIZE;
|
|
642
|
+
const block = await stream.read(BLOCK_SIZE);
|
|
643
|
+
if (block.byteLength < BLOCK_SIZE) throw new ArchiveError("Archive sparse metadata is truncated");
|
|
644
|
+
extended = block[GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
|
|
510
645
|
}
|
|
511
646
|
}
|
|
512
|
-
const dataOffset =
|
|
513
|
-
|
|
514
|
-
if (memberDataBlocks > buffer.byteLength - dataOffset) {
|
|
647
|
+
const dataOffset = stream.position;
|
|
648
|
+
if (!(await stream.skip(paddedSize(size)))) {
|
|
515
649
|
throw new ArchiveError(`Archive member '${formatArchivePathForError(name)}' is truncated`);
|
|
516
650
|
}
|
|
517
|
-
offset += memberDataBlocks;
|
|
518
651
|
longName = undefined;
|
|
519
652
|
longLink = undefined;
|
|
520
653
|
localPax = undefined;
|
|
@@ -567,7 +700,7 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
|
|
|
567
700
|
size: displaySize,
|
|
568
701
|
mtimeMs,
|
|
569
702
|
mode,
|
|
570
|
-
storage: { type: "member", source: new TarMemberSource(
|
|
703
|
+
storage: { type: "member", source: new TarMemberSource(payload, dataOffset, sparse) },
|
|
571
704
|
});
|
|
572
705
|
}
|
|
573
706
|
if (!sawTerminator) throw new ArchiveError("Not a valid tar archive: missing terminating zero block");
|
|
@@ -575,6 +708,35 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
|
|
|
575
708
|
return [...entries.values()];
|
|
576
709
|
}
|
|
577
710
|
|
|
711
|
+
/** Index an already-decompressed tar buffer; members are views into it. */
|
|
712
|
+
export async function readTarEntriesFromBuffer(
|
|
713
|
+
buffer: Uint8Array,
|
|
714
|
+
options: FormatReadOptions,
|
|
715
|
+
): Promise<ArchiveIndexEntry[]> {
|
|
716
|
+
assertInMemorySize(buffer.byteLength, options.limits);
|
|
717
|
+
return readTarEntries(new TarByteStream([buffer]), new TarBufferPayload(buffer), options);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Index a decompressed tar stream that is too large to hold in memory: one
|
|
722
|
+
* pass over `open()` walks the headers, and member reads open it again.
|
|
723
|
+
*/
|
|
724
|
+
export async function readTarEntriesFromStream(
|
|
725
|
+
open: () => AsyncIterable<Uint8Array>,
|
|
726
|
+
options: FormatReadOptions,
|
|
727
|
+
): Promise<ArchiveIndexEntry[]> {
|
|
728
|
+
const stream = new TarByteStream(open());
|
|
729
|
+
try {
|
|
730
|
+
const entries = await readTarEntries(stream, new TarStreamPayload(open), options);
|
|
731
|
+
// Decompress past the terminator to the end, so the codec still checks the
|
|
732
|
+
// stream's trailer (gzip CRC and length) as a whole-stream decompression does.
|
|
733
|
+
await stream.skip(Number.POSITIVE_INFINITY);
|
|
734
|
+
return entries;
|
|
735
|
+
} finally {
|
|
736
|
+
await stream.close();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
578
740
|
/** Read and index a tar source after one bounded whole-stream read. */
|
|
579
741
|
export const readTar: FormatReader = async (source, options) => {
|
|
580
742
|
assertInMemorySize(source.size, options.limits);
|
package/src/dirs.ts
CHANGED
|
@@ -17,6 +17,7 @@ import * as path from "node:path";
|
|
|
17
17
|
import { expandWindowsLongPath } from "@oh-my-pi/pi-natives/path";
|
|
18
18
|
import { engines, version } from "../package.json" with { type: "json" };
|
|
19
19
|
import { isEnoent, isEnotdir } from "./fs-error";
|
|
20
|
+
import { isBunTestRuntime } from "./test-runtime";
|
|
20
21
|
|
|
21
22
|
/** App name (e.g. "omp") */
|
|
22
23
|
export const APP_NAME: string = "omp";
|
|
@@ -160,12 +161,33 @@ function standardizeProjectPath(p: string): string {
|
|
|
160
161
|
return process.platform === "win32" ? expandWindowsLongPath(p) : standardizeMacOSPath(p);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
export
|
|
164
|
+
export interface EquivalentPathOptions {
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
|
|
167
|
+
* missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
|
|
168
|
+
*/
|
|
169
|
+
throughMissing?: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string {
|
|
164
173
|
const resolvedPath = path.resolve(inputPath);
|
|
165
174
|
try {
|
|
166
175
|
return fs.realpathSync(resolvedPath);
|
|
167
176
|
} catch {
|
|
168
|
-
return resolvedPath;
|
|
177
|
+
if (!options?.throughMissing) return resolvedPath;
|
|
178
|
+
}
|
|
179
|
+
const missing: string[] = [];
|
|
180
|
+
let current = resolvedPath;
|
|
181
|
+
for (;;) {
|
|
182
|
+
const parent = path.dirname(current);
|
|
183
|
+
if (parent === current) return resolvedPath;
|
|
184
|
+
missing.unshift(path.basename(current));
|
|
185
|
+
current = parent;
|
|
186
|
+
try {
|
|
187
|
+
return path.join(fs.realpathSync(current), ...missing);
|
|
188
|
+
} catch {
|
|
189
|
+
// Keep walking: a higher ancestor may exist.
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
}
|
|
171
193
|
|
|
@@ -634,8 +656,17 @@ export function getReportsDir(): string {
|
|
|
634
656
|
return dirs.rootSubdir("reports", "state");
|
|
635
657
|
}
|
|
636
658
|
|
|
637
|
-
/**
|
|
659
|
+
/**
|
|
660
|
+
* Get the logs directory (~/.omp/logs). `bun test` processes get
|
|
661
|
+
* `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
|
|
662
|
+
* developer's real logs; the uid suffix (as for the test session-owners dir)
|
|
663
|
+
* keeps users of a shared `/tmp` from owning each other's directory.
|
|
664
|
+
*/
|
|
638
665
|
export function getLogsDir(): string {
|
|
666
|
+
if (isBunTestRuntime()) {
|
|
667
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username;
|
|
668
|
+
return path.join(os.tmpdir(), `omp-test-logs-${uid}`);
|
|
669
|
+
}
|
|
639
670
|
return dirs.rootSubdir("logs", "state");
|
|
640
671
|
}
|
|
641
672
|
|
package/src/env.ts
CHANGED
|
@@ -3,7 +3,9 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { parseEnv } from "node:util";
|
|
5
5
|
import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
|
|
6
|
+
import { isBunTestRuntime } from "./test-runtime";
|
|
6
7
|
|
|
8
|
+
export * from "./test-runtime";
|
|
7
9
|
export * from "./worker-host";
|
|
8
10
|
|
|
9
11
|
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -378,15 +380,6 @@ export function $envpos(name: string, defaultValue: number): number {
|
|
|
378
380
|
return parsed;
|
|
379
381
|
}
|
|
380
382
|
|
|
381
|
-
const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
|
|
382
|
-
|
|
383
|
-
/** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
|
|
384
|
-
export function isBunTestRuntime(): boolean {
|
|
385
|
-
if (Bun.env.PI_TEST_RUNTIME === "1") return true;
|
|
386
|
-
const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
|
|
387
|
-
return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
383
|
let terminalHeadless = isBunTestRuntime();
|
|
391
384
|
|
|
392
385
|
/**
|
package/src/frontmatter.ts
CHANGED
|
@@ -2,8 +2,13 @@ import { YAML } from "bun";
|
|
|
2
2
|
import { truncate } from "./format";
|
|
3
3
|
import * as logger from "./logger";
|
|
4
4
|
|
|
5
|
-
function
|
|
6
|
-
|
|
5
|
+
function stripLeadingHtmlComments(content: string): string {
|
|
6
|
+
const leading = /^(?:(?:\s*<!--[\s\S]*?-->)+)/.exec(content);
|
|
7
|
+
if (!leading) return content;
|
|
8
|
+
const rest = content.slice(leading[0].length);
|
|
9
|
+
// Only repair a comment that precedes frontmatter; leave a plain document
|
|
10
|
+
// (and every body) byte-identical so HTML comments inside it survive.
|
|
11
|
+
return /^\s*---/.test(rest) ? rest.replace(/^\s+/, "") : content;
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
/** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
|
|
@@ -142,7 +147,8 @@ export function parseFrontmatter(
|
|
|
142
147
|
const frontmatter: Record<string, unknown> = { ...fallback };
|
|
143
148
|
|
|
144
149
|
const newlineNormalized = normalize ? content.replace(/\r\n?/g, "\n") : content;
|
|
145
|
-
const
|
|
150
|
+
const repairHtmlComments = normalize && repair;
|
|
151
|
+
const normalized = repairHtmlComments ? stripLeadingHtmlComments(newlineNormalized) : newlineNormalized;
|
|
146
152
|
if (!normalized.startsWith("---")) {
|
|
147
153
|
return { frontmatter, body: normalized };
|
|
148
154
|
}
|
|
@@ -152,7 +158,8 @@ export function parseFrontmatter(
|
|
|
152
158
|
return { frontmatter, body: normalized };
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
const
|
|
161
|
+
const rawMetadata = normalized.slice(4, endIndex);
|
|
162
|
+
const metadata = repairHtmlComments ? rawMetadata.replace(/<!--[\s\S]*?-->/g, "") : rawMetadata;
|
|
156
163
|
const body = normalized.slice(endIndex + 4).trim();
|
|
157
164
|
|
|
158
165
|
try {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
|
|
2
|
+
|
|
3
|
+
/** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
|
|
4
|
+
export function isBunTestRuntime(): boolean {
|
|
5
|
+
if (Bun.env.PI_TEST_RUNTIME === "1") return true;
|
|
6
|
+
const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
|
|
7
|
+
return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
|
|
8
|
+
}
|