@oh-my-pi/pi-utils 18.15.0 → 18.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.17.0] - 2026-11-06
6
+
7
+ ### Added
8
+
9
+ - Added `getUpdateStateDir()`, returning the auto-update state directory (`~/.omp/update`).
10
+ - Added `logger.setLogForwarder()` and `logger.emitForeign()` to forward log records across threads, preserving context and timestamps.
11
+ - Added `expandTilde()` to expand a leading `~` to the home directory.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed `spawn`/`exec` timeouts never firing when the command's root exited before `spawn()` returned, which let a descendant holding stdout or stderr stall output reads until it exited.
16
+ - `ptree` commands with `subreaper: true` now reliably terminate all descendants on graceful shutdown.
17
+ - `fatal()` in a worker thread now reaches the parent's `error` listeners instead of only exiting with code 1.
18
+ - Fixed the terminal stderr guard leaking a copy of the terminal into processes spawned from native code.
19
+
20
+ ## [18.16.0] - 2026-11-02
21
+
22
+ ### Added
23
+
24
+ - Added `isPidAlive` (signal-0 liveness probe) and `asStrictBytes` (a `Uint8Array` narrowed to its own `ArrayBuffer` for WebCrypto)
25
+ - Added `ensureSqliteLockShim()`, which `openSqliteDatabase`/`openSqliteDatabaseSync` call before opening a store, so on Linux the process's SQLite locks are open-file-description locks that other closes of the file cannot release ([#15586](https://github.com/can1357/oh-my-pi/issues/15586))
26
+ - Added `assertNotHeldOpen(path)` (`@oh-my-pi/pi-utils/held-file`), which throws `EBUSY` for a SQLite database or `-shm` this process holds open ([#15586](https://github.com/can1357/oh-my-pi/issues/15586))
27
+
28
+ ### Changed
29
+
30
+ - `acp` runtime validators (`zNewSessionResponse`, `zSessionNotification`, …) now report each failing field's path and reason in `error.issues` instead of a single `Invalid <label>`; `ValidationError` issue paths are typed `PropertyKey[]` ([#17699](https://github.com/can1357/oh-my-pi/pull/17699))
31
+
5
32
  ## [18.15.0] - 2026-11-01
6
33
 
7
34
  ### Added
@@ -2,7 +2,7 @@ import type { ForkSessionResponse, LoadSessionResponse, NewSessionResponse, Prom
2
2
  /** Validation failure compatible with the used Zod error surface. */
3
3
  export interface ValidationError {
4
4
  issues: Array<{
5
- path: Array<string | number>;
5
+ path: PropertyKey[];
6
6
  message: string;
7
7
  }>;
8
8
  }
@@ -1,3 +1,7 @@
1
+ /** Undo the delta filter (shared by XZ and 7z) in place. */
2
+ export declare function deltaDecode(bytes: Uint8Array, distance: number): void;
3
+ /** Undo the x86 BCJ filter (shared by XZ and 7z) in place; `startOffset` is the stream position of `bytes[0]`. */
4
+ export declare function x86Decode(bytes: Uint8Array, startOffset: number): void;
1
5
  /** Whether bytes begin with the XZ stream-header magic. */
2
6
  export declare function isXz(bytes: Uint8Array): boolean;
3
7
  /** Decompress all concatenated streams in an XZ container within `maxOutput`. */
@@ -1,6 +1,8 @@
1
1
  import type { FormatReader } from "./types.js";
2
2
  /** @internal Decode the static-Huffman LZSS stream shared by LZH and ARJ methods 1-3. */
3
3
  export declare function decompressLhStatic(packed: Uint8Array, outSize: number, dictionarySize: number, positionBits: number, positionSymbols: number, label: string): Uint8Array;
4
+ /** MS-DOS packed date/time as UTC milliseconds; `undefined` for 0 or an out-of-range field. */
5
+ export declare function dosTimeToMs(value: number): number | undefined;
4
6
  /** Probe whether bytes begin with an LZH/LHA member header. */
5
7
  export declare function sniffLzh(bytes: Uint8Array): boolean;
6
8
  /** Index an LZH/LHA archive and lazily decode its members from the bounded archive buffer. */
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Narrow `bytes` to a view over its own whole `ArrayBuffer`, which is what
3
+ * `crypto.subtle.{digest,sign,importKey,encrypt,decrypt}` require under the
4
+ * strict TS DOM typings. Returns `bytes` itself when it already is one,
5
+ * otherwise a copy.
6
+ */
7
+ export declare function asStrictBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer>;
@@ -142,6 +142,12 @@ export declare function getAgentDir(): string;
142
142
  export declare function getProjectAgentDir(cwd?: string): string;
143
143
  /** Get the reports directory (~/.omp/reports). */
144
144
  export declare function getReportsDir(): string;
145
+ /**
146
+ * Get the self-update state directory (~/.omp/update): the background
147
+ * auto-update's status file and worker log, and the lock that keeps two
148
+ * installs (`omp update`, auto-update) from running at once.
149
+ */
150
+ export declare function getUpdateStateDir(): string;
145
151
  /**
146
152
  * Get the logs directory (~/.omp/logs). `bun test` processes get
147
153
  * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Throws an `EBUSY` error when `filePath` is a file this process holds open with
3
+ * locks: a SQLite database or `-shm` of a live connection. Always passes outside Linux.
4
+ */
5
+ export declare function assertNotHeldOpen(filePath: string): void;
@@ -1,6 +1,7 @@
1
1
  export { once, untilAborted } from "./abortable.js";
2
2
  export * from "./async.js";
3
3
  export * from "./binary.js";
4
+ export * from "./bytes.js";
4
5
  export * from "./color.js";
5
6
  export * from "./dirs.js";
6
7
  export * from "./env.js";
@@ -22,6 +23,7 @@ export * from "./mime.js";
22
23
  export * from "./path.js";
23
24
  export * from "./path-tree.js";
24
25
  export * from "./peek-file.js";
26
+ export * from "./pid.js";
25
27
  export * as postmortem from "./postmortem.js";
26
28
  export * from "./process-name.js";
27
29
  export * as procmgr from "./procmgr.js";
@@ -9,6 +9,35 @@ export interface LogEvent {
9
9
  }
10
10
  /** Receives each structured log event after the local transport path runs. */
11
11
  export type LogSink = (event: LogEvent) => void;
12
+ /**
13
+ * Destination that takes this thread's log records in place of its own
14
+ * transports and sinks (a worker thread routing records to the main thread's
15
+ * logger); see {@link setLogForwarder}.
16
+ */
17
+ export interface LogForwarder {
18
+ /**
19
+ * Take one record. Its context is already merged over the enclosing
20
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
21
+ * a structured clone; `timestamp` is when the record was logged.
22
+ */
23
+ emit(event: LogEvent): void;
24
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
25
+ flush(): void;
26
+ }
27
+ /**
28
+ * Route every later record logged on this thread to `forwarder` instead of the
29
+ * local transports and sinks, which then stay unused (no log file is opened);
30
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
31
+ * For worker threads, whose main thread owns the process log file.
32
+ */
33
+ export declare function setLogForwarder(forwarder: LogForwarder | undefined): void;
34
+ /**
35
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
36
+ * to the local transports with its own timestamp, then handed to the sinks. Its
37
+ * context is taken as is; the caller's enclosing {@link withContext} is not
38
+ * merged in. On a thread that forwards itself, the record is forwarded on.
39
+ */
40
+ export declare function emitForeign(event: LogEvent): void;
12
41
  /** Register an out-of-band log sink and return a disposer. */
13
42
  export declare function registerLogSink(sink: LogSink): () => void;
14
43
  /**
@@ -8,3 +8,5 @@ export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, p
8
8
  * On POSIX, requires an absolute path.
9
9
  */
10
10
  export declare function isFullyQualifiedPath(filePath: string, platform?: NodeJS.Platform): boolean;
11
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
12
+ export declare function expandTilde(filePath: string, home?: string): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Signal-0 liveness probe: true unless the OS reports the pid as gone
3
+ * (`ESRCH`) or invalid (`EINVAL`). Any other failure, notably `EPERM` (alive
4
+ * but owned by another user), reads as alive, so callers never reclaim a
5
+ * resource a live process may still hold. Unlike `procmgr.isPidRunning`, this
6
+ * does not consult the process table's run state.
7
+ */
8
+ export declare function isPidAlive(pid: number): boolean;
@@ -40,7 +40,7 @@ export declare function resolveBasicShell(): string | undefined;
40
40
  * Resolve the external shell to advertise on Windows.
41
41
  *
42
42
  * A host bash is OPTIONAL: bash tool commands always execute in the embedded
43
- * brush-core shell. The resolved binary only serves the spawn-a-shell paths
43
+ * bash engine (pi-shell). The resolved binary only serves the spawn-a-shell paths
44
44
  * (interactive PTY sessions, ACP client terminals, SHELL env), so this
45
45
  * prefers a real Git Bash when one exists and otherwise falls back to
46
46
  * cmd.exe — it never fails.
@@ -1,5 +1,17 @@
1
1
  /** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
2
2
  import { Database } from "bun:sqlite";
3
+ /**
4
+ * Moves this process's SQLite locks onto open-file-description locks (Linux), once.
5
+ *
6
+ * SQLite's POSIX record locks belong to the process, so any close of another
7
+ * descriptor of a database or its `-shm` released them, including Bun's own file
8
+ * APIs and the bash tool's builtins; a peer omp then unlinked the WAL under the live
9
+ * connection (issue #15586). Call before the process opens its first database: a
10
+ * connection opened earlier keeps POSIX locks. Returns whether the shim is active; a
11
+ * Linux build where it cannot be installed (Bun's SQLite layout changed) logs a warning
12
+ * once, and callers with a UI surface it.
13
+ */
14
+ export declare function ensureSqliteLockShim(): boolean;
3
15
  /**
4
16
  * A store's identity as `dev:ino:birthtimeMs`, `null` when the path holds no
5
17
  * file, or `undefined` when the stat itself failed and no conclusion is safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.15.0",
3
+ "version": "18.17.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -49,14 +49,15 @@
49
49
  },
50
50
  "scripts": {
51
51
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
52
- "check:types": "tsgo -p tsconfig.json --noEmit",
52
+ "check:types": "bun check --no-script -p tsconfig.json",
53
53
  "lint": "oxlint .",
54
54
  "test": "bun test --parallel",
55
55
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
56
56
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
57
57
  },
58
58
  "dependencies": {
59
- "@oh-my-pi/pi-natives": "18.15.0"
59
+ "@oh-my-pi/omptype": "18.17.0",
60
+ "@oh-my-pi/pi-natives": "18.17.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/bun": "^1.3.14"
package/src/acp/schema.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Type, type } from "@oh-my-pi/omptype";
1
2
  import type {
2
3
  ForkSessionResponse,
3
4
  LoadSessionResponse,
@@ -8,7 +9,7 @@ import type {
8
9
 
9
10
  /** Validation failure compatible with the used Zod error surface. */
10
11
  export interface ValidationError {
11
- issues: Array<{ path: Array<string | number>; message: string }>;
12
+ issues: Array<{ path: PropertyKey[]; message: string }>;
12
13
  }
13
14
  /** Successful validation result. */
14
15
  export interface ValidationSuccess<T> {
@@ -26,129 +27,74 @@ export interface Validator<T> {
26
27
  parse(value: unknown): T;
27
28
  }
28
29
 
29
- function validator<T>(check: (value: unknown) => boolean, label: string): Validator<T> {
30
+ /**
31
+ * Wraps a structural schema in the Zod call shape. Schemas check the fields
32
+ * ACP consumers read and keep every other key, so the result is the full
33
+ * protocol value.
34
+ */
35
+ function validator<T>(schema: Type, label: string): Validator<T> {
30
36
  return {
31
37
  safeParse(value) {
32
- return check(value)
33
- ? { success: true, data: value as T }
34
- : { success: false, error: { issues: [{ path: [], message: `Invalid ${label}` }] } };
38
+ const result = schema(value);
39
+ if (!(result instanceof type.errors)) return { success: true, data: result as T };
40
+ return {
41
+ success: false,
42
+ error: { issues: result.map(issue => ({ path: [...issue.path], message: issue.message })) },
43
+ };
35
44
  },
36
45
  parse(value) {
37
- if (!check(value)) throw new Error(`Invalid ${label}`);
38
- return value as T;
46
+ const result = schema(value);
47
+ if (result instanceof type.errors) throw new Error(`Invalid ${label}: ${result.summary}`);
48
+ return result as T;
39
49
  },
40
50
  };
41
51
  }
42
52
 
43
- function objectWithString(value: unknown, key: string): boolean {
44
- return typeof value === "object" && value !== null && typeof (value as Record<string, unknown>)[key] === "string";
45
- }
46
-
47
- function validModes(value: unknown): boolean {
48
- if (value === undefined || value === null) return true;
49
- if (typeof value !== "object" || value === null) return false;
50
- const modes = value as Record<string, unknown>;
51
- return (
52
- typeof modes.currentModeId === "string" &&
53
- Array.isArray(modes.availableModes) &&
54
- modes.availableModes.every(mode => objectWithString(mode, "id") && objectWithString(mode, "name"))
55
- );
56
- }
53
+ const Modes = type({
54
+ currentModeId: "string",
55
+ availableModes: type({ id: "string", name: "string" }).array(),
56
+ }).or("null | undefined");
57
57
 
58
- function sessionResponse(value: unknown, needsId: boolean): boolean {
59
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
60
- const response = value as Record<string, unknown>;
61
- if (needsId && typeof response.sessionId !== "string") return false;
62
- if (!validModes(response.modes)) return false;
63
- return (
64
- response.configOptions === undefined || response.configOptions === null || Array.isArray(response.configOptions)
65
- );
66
- }
58
+ // Responses and notifications are also checked in-process, where absent optionals may be explicit `undefined`.
59
+ const sessionResponseFields = { "modes?": Modes, "configOptions?": "unknown[] | null | undefined" } as const;
60
+ const notArray = (value: object): boolean => !Array.isArray(value);
61
+ const SessionResponse = type(sessionResponseFields).narrow(notArray);
62
+ const IdentifiedSessionResponse = type({ sessionId: "string", ...sessionResponseFields }).narrow(notArray);
67
63
 
68
- function contentBlock(value: unknown): boolean {
69
- if (typeof value !== "object" || value === null) return false;
70
- const block = value as Record<string, unknown>;
71
- switch (block.type) {
72
- case "text":
73
- return typeof block.text === "string";
74
- case "image":
75
- case "audio":
76
- return typeof block.data === "string" && typeof block.mimeType === "string";
77
- case "resource_link":
78
- return typeof block.uri === "string" && typeof block.name === "string";
79
- case "resource":
80
- return typeof block.resource === "object" && block.resource !== null;
81
- default:
82
- return false;
83
- }
84
- }
64
+ const ContentBlock = type({ type: "'text'", text: "string" })
65
+ .or({ type: "'image' | 'audio'", data: "string", mimeType: "string" })
66
+ .or({ type: "'resource_link'", uri: "string", name: "string" })
67
+ .or({ type: "'resource'", resource: "object" });
85
68
 
86
- function sessionNotification(value: unknown): boolean {
87
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
88
- const notification = value as Record<string, unknown>;
89
- if (
90
- typeof notification.sessionId !== "string" ||
91
- typeof notification.update !== "object" ||
92
- notification.update === null
93
- )
94
- return false;
95
- const update = notification.update as Record<string, unknown>;
96
- if (typeof update.sessionUpdate !== "string") return false;
97
- switch (update.sessionUpdate) {
98
- case "agent_message_chunk":
99
- case "agent_thought_chunk":
100
- case "user_message_chunk":
101
- return contentBlock(update.content);
102
- case "tool_call":
103
- return typeof update.toolCallId === "string" && typeof update.title === "string";
104
- case "tool_call_update":
105
- return typeof update.toolCallId === "string";
106
- case "plan":
107
- return Array.isArray(update.entries);
108
- case "current_mode_update":
109
- return typeof update.currentModeId === "string";
110
- case "available_commands_update":
111
- return Array.isArray(update.availableCommands);
112
- case "config_option_update":
113
- return Array.isArray(update.configOptions);
114
- case "session_info_update":
115
- return update.title === undefined || update.title === null || typeof update.title === "string";
116
- case "usage_update":
117
- return typeof update.size === "number" && typeof update.used === "number";
118
- default:
119
- return false;
120
- }
121
- }
69
+ const SessionUpdate = type({
70
+ sessionUpdate: "'agent_message_chunk' | 'agent_thought_chunk' | 'user_message_chunk'",
71
+ content: ContentBlock,
72
+ })
73
+ .or({ sessionUpdate: "'tool_call'", toolCallId: "string", title: "string" })
74
+ .or({ sessionUpdate: "'tool_call_update'", toolCallId: "string" })
75
+ .or({ sessionUpdate: "'plan'", entries: "unknown[]" })
76
+ .or({ sessionUpdate: "'current_mode_update'", currentModeId: "string" })
77
+ .or({ sessionUpdate: "'available_commands_update'", availableCommands: "unknown[]" })
78
+ .or({ sessionUpdate: "'config_option_update'", configOptions: "unknown[]" })
79
+ .or({ sessionUpdate: "'session_info_update'", "title?": "string | null | undefined" })
80
+ .or({ sessionUpdate: "'usage_update'", size: "number", used: "number" });
122
81
 
123
82
  /** Validator for new-session responses. */
124
- export const zNewSessionResponse = validator<NewSessionResponse>(
125
- value => sessionResponse(value, true),
126
- "new session response",
127
- );
83
+ export const zNewSessionResponse = validator<NewSessionResponse>(IdentifiedSessionResponse, "new session response");
128
84
  /** Validator for load-session responses. */
129
- export const zLoadSessionResponse = validator<LoadSessionResponse>(
130
- value => sessionResponse(value, false),
131
- "load session response",
132
- );
85
+ export const zLoadSessionResponse = validator<LoadSessionResponse>(SessionResponse, "load session response");
133
86
  /** Validator for fork-session responses. */
134
- export const zForkSessionResponse = validator<ForkSessionResponse>(
135
- value => sessionResponse(value, true),
136
- "fork session response",
137
- );
87
+ export const zForkSessionResponse = validator<ForkSessionResponse>(IdentifiedSessionResponse, "fork session response");
138
88
  /** Validator for prompt responses. */
139
- export const zPromptResponse = validator<PromptResponse>(value => {
140
- if (typeof value !== "object" || value === null) return false;
141
- const stopReason = (value as Record<string, unknown>).stopReason;
142
- return (
143
- stopReason === "end_turn" ||
144
- stopReason === "max_tokens" ||
145
- stopReason === "max_turn_requests" ||
146
- stopReason === "refusal" ||
147
- stopReason === "cancelled"
148
- );
149
- }, "prompt response");
89
+ export const zPromptResponse = validator<PromptResponse>(
90
+ type({ stopReason: "'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'" }),
91
+ "prompt response",
92
+ );
150
93
  /** Validator for session notifications. */
151
- export const zSessionNotification = validator<SessionNotification>(sessionNotification, "session notification");
94
+ export const zSessionNotification = validator<SessionNotification>(
95
+ type({ sessionId: "string", update: SessionUpdate }).narrow(notArray),
96
+ "session notification",
97
+ );
152
98
 
153
99
  /** ACP runtime validators. */
154
100
  export const schema = {
package/src/ar/arj.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { readUInt16LE, readUInt32LE } from "./bytes";
1
2
  import { crc32 } from "./checksums";
2
3
  import { ArchiveError } from "./error";
3
4
  import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
4
- import { decompressLhStatic } from "./lzh";
5
+ import { decompressLhStatic, dosTimeToMs } from "./lzh";
5
6
  import { assertArchivePathBytes, assertArchivePathString, normalizeArchiveEntryPath } from "./paths";
6
7
  import { type ByteSource, readAllBytes } from "./source";
7
8
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
@@ -11,14 +12,6 @@ const ARJ_SIGNATURE_1 = 0xea;
11
12
  const ARJ_MAX_BASIC_HEADER = 2600;
12
13
  const LEGACY_DECODER = new TextDecoder("windows-1252");
13
14
 
14
- function u16(bytes: Uint8Array, offset: number): number {
15
- return bytes[offset]! | (bytes[offset + 1]! << 8);
16
- }
17
-
18
- function u32(bytes: Uint8Array, offset: number): number {
19
- return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
20
- }
21
-
22
15
  function assertRange(bytes: Uint8Array, start: number, end: number, what: string): void {
23
16
  if (
24
17
  !Number.isSafeInteger(start) ||
@@ -31,18 +24,6 @@ function assertRange(bytes: Uint8Array, start: number, end: number, what: string
31
24
  }
32
25
  }
33
26
 
34
- function dosTimeToMs(value: number): number | undefined {
35
- if (value === 0) return undefined;
36
- const year = 1980 + ((value >>> 25) & 0x7f);
37
- const month = (value >>> 21) & 0x0f;
38
- const day = (value >>> 16) & 0x1f;
39
- const hour = (value >>> 11) & 0x1f;
40
- const minute = (value >>> 5) & 0x3f;
41
- const second = (value & 0x1f) * 2;
42
- if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined;
43
- return Date.UTC(year, month - 1, day, hour, minute, second);
44
- }
45
-
46
27
  function readCString(bytes: Uint8Array, start: number, end: number, field: string): { value: string; next: number } {
47
28
  let terminator = start;
48
29
  while (terminator < end && bytes[terminator] !== 0) terminator++;
@@ -63,26 +44,26 @@ function parseArjBlock(bytes: Uint8Array, offset: number, options: FormatReadOpt
63
44
  if (bytes[offset] !== ARJ_SIGNATURE_0 || bytes[offset + 1] !== ARJ_SIGNATURE_1) {
64
45
  throw new ArchiveError("Invalid ARJ header signature");
65
46
  }
66
- const bodySize = u16(bytes, offset + 2);
47
+ const bodySize = readUInt16LE(bytes, offset + 2);
67
48
  if (bodySize === 0)
68
49
  return { bodyStart: offset + 4, bodySize: 0, nextOffset: offset + 4, metadataSize: 4, isEnd: true };
69
50
  if (bodySize < 30 || bodySize > ARJ_MAX_BASIC_HEADER) throw new ArchiveError("Invalid ARJ basic header size");
70
51
  const bodyStart = offset + 4;
71
52
  const bodyEnd = bodyStart + bodySize;
72
53
  assertRange(bytes, bodyStart, bodyEnd + 4, "basic header");
73
- if (crc32(bytes.subarray(bodyStart, bodyEnd)) !== u32(bytes, bodyEnd)) {
54
+ if (crc32(bytes.subarray(bodyStart, bodyEnd)) !== readUInt32LE(bytes, bodyEnd)) {
74
55
  throw new ArchiveError("Invalid ARJ basic header CRC32");
75
56
  }
76
57
  let cursor = bodyEnd + 4;
77
58
  let extensionCount = 0;
78
59
  for (;;) {
79
60
  assertRange(bytes, cursor, cursor + 2, "extended header size");
80
- const extensionSize = u16(bytes, cursor);
61
+ const extensionSize = readUInt16LE(bytes, cursor);
81
62
  cursor += 2;
82
63
  if (extensionSize === 0) break;
83
64
  if (++extensionCount > 65_535) throw new ArchiveError("Invalid ARJ archive: too many extended headers");
84
65
  assertRange(bytes, cursor, cursor + extensionSize + 4, "extended header");
85
- if (crc32(bytes.subarray(cursor, cursor + extensionSize)) !== u32(bytes, cursor + extensionSize)) {
66
+ if (crc32(bytes.subarray(cursor, cursor + extensionSize)) !== readUInt32LE(bytes, cursor + extensionSize)) {
86
67
  throw new ArchiveError("Invalid ARJ extended header CRC32");
87
68
  }
88
69
  cursor += extensionSize + 4;
@@ -216,10 +197,10 @@ function normalizeHostPath(rawPath: string, hostOs: number): string {
216
197
  /** Probe whether bytes begin with a CRC-framed ARJ main header. */
217
198
  export function sniffArj(bytes: Uint8Array): boolean {
218
199
  if (bytes.byteLength < 34 || bytes[0] !== ARJ_SIGNATURE_0 || bytes[1] !== ARJ_SIGNATURE_1) return false;
219
- const size = u16(bytes, 2);
200
+ const size = readUInt16LE(bytes, 2);
220
201
  if (size < 30 || size > ARJ_MAX_BASIC_HEADER || bytes.byteLength < size + 8) return false;
221
202
  const body = bytes.subarray(4, 4 + size);
222
- return body[0]! >= 30 && body[0]! <= size && body[6] === 2 && crc32(body) === u32(bytes, 4 + size);
203
+ return body[0]! >= 30 && body[0]! <= size && body[6] === 2 && crc32(body) === readUInt32LE(bytes, 4 + size);
223
204
  }
224
205
 
225
206
  /** Index an ARJ archive and lazily decode stored, static-Huffman, and fast-LZSS members. */
@@ -266,10 +247,10 @@ export const readArj: FormatReader = async (
266
247
  const fileType = bytes[block.bodyStart + 6]!;
267
248
  if ((flags & 0x01) !== 0) throw new ArchiveError("Encrypted ARJ members are unsupported");
268
249
  if ((flags & 0x0c) !== 0) throw new ArchiveError("Multi-volume ARJ members are unsupported");
269
- const packedSize = u32(bytes, block.bodyStart + 12);
270
- const size = u32(bytes, block.bodyStart + 16);
271
- const fileCrc = u32(bytes, block.bodyStart + 20);
272
- const accessMode = u16(bytes, block.bodyStart + 26);
250
+ const packedSize = readUInt32LE(bytes, block.bodyStart + 12);
251
+ const size = readUInt32LE(bytes, block.bodyStart + 16);
252
+ const fileCrc = readUInt32LE(bytes, block.bodyStart + 20);
253
+ const accessMode = readUInt16LE(bytes, block.bodyStart + 26);
273
254
  const filename = readCString(
274
255
  bytes,
275
256
  block.bodyStart + firstHeaderSize,
@@ -295,7 +276,7 @@ export const readArj: FormatReader = async (
295
276
  if ((hostOs === 2 || hostOs === 8) && accessMode !== 0) {
296
277
  mode = (isDirectory ? 0x4000 : 0x8000) | (accessMode & 0x0fff);
297
278
  }
298
- const rawMtime = u32(bytes, block.bodyStart + 8);
279
+ const rawMtime = readUInt32LE(bytes, block.bodyStart + 8);
299
280
  const mtimeMs =
300
281
  hostOs === 2 || hostOs === 8 ? (rawMtime === 0 ? undefined : rawMtime * 1000) : dosTimeToMs(rawMtime);
301
282
  entries.push({
package/src/ar/asar.ts CHANGED
@@ -2,6 +2,7 @@ import * as path from "node:path";
2
2
  import { readUInt32LE, writeUInt32LE } from "./bytes";
3
3
  import { ArchiveError } from "./error";
4
4
  import { assertArchiveMemberSize, assertEntryCount, assertIndexSize } from "./limits";
5
+ import { isRecord } from "../type-guards";
5
6
  import {
6
7
  assertArchivePathBytes,
7
8
  assertArchivePathString,
@@ -55,14 +56,10 @@ function alignAsarPayload(size: number): number {
55
56
  return size + ((4 - (size % 4)) % 4);
56
57
  }
57
58
 
58
- function isArchiveRecord(value: unknown): value is Record<string, unknown> {
59
- return typeof value === "object" && value !== null && !Array.isArray(value);
60
- }
61
-
62
59
  function parseIntegrity(value: unknown, entryPath: string, size: number): AsarIntegrity | undefined {
63
60
  if (value === undefined) return undefined;
64
61
  const label = formatArchivePathForError(entryPath);
65
- if (!isArchiveRecord(value)) {
62
+ if (!isRecord(value)) {
66
63
  throw invalidAsar(`file '${label}' has an invalid integrity record`);
67
64
  }
68
65
  if (value.algorithm !== "SHA256") {
@@ -228,7 +225,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
228
225
  } catch {
229
226
  throw invalidAsar("header is not valid JSON");
230
227
  }
231
- if (!isArchiveRecord(header) || !isArchiveRecord(header.files)) {
228
+ if (!isRecord(header) || !isRecord(header.files)) {
232
229
  throw invalidAsar("root must contain a files object");
233
230
  }
234
231
 
@@ -257,7 +254,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
257
254
  ) {
258
255
  throw invalidAsar(`invalid entry name '${formatArchivePathForError(current.name)}'`);
259
256
  }
260
- if (!isArchiveRecord(current.node)) {
257
+ if (!isRecord(current.node)) {
261
258
  throw invalidAsar(`entry '${formatArchivePathForError(current.name)}' must be an object`);
262
259
  }
263
260
  const entryPathBytes = current.parentPathBytes + (current.parentPath ? 1 : 0) + nameBytes;
@@ -295,7 +292,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
295
292
  }
296
293
 
297
294
  if ("files" in current.node) {
298
- if (!isArchiveRecord(current.node.files)) {
295
+ if (!isRecord(current.node.files)) {
299
296
  throw invalidAsar(`directory '${entryLabel}' must contain a files object`);
300
297
  }
301
298
  entries.push({ path: entryPath, isDirectory: true, size: 0 });
@@ -1,3 +1,4 @@
1
+ import { readUInt32BE } from "../bytes";
1
2
  import { crc32, crc64 } from "../checksums";
2
3
  import { ArchiveError } from "../error";
3
4
  import { lzma2Decompress } from "./lzma";
@@ -16,10 +17,6 @@ function write32LE(bytes: Uint8Array, offset: number, value: number): void {
16
17
  bytes[offset + 3] = value >>> 24;
17
18
  }
18
19
 
19
- function read32BE(bytes: Uint8Array, offset: number): number {
20
- return ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0;
21
- }
22
-
23
20
  function write32BE(bytes: Uint8Array, offset: number, value: number): void {
24
21
  bytes[offset] = value >>> 24;
25
22
  bytes[offset + 1] = (value >>> 16) & 0xff;
@@ -155,7 +152,8 @@ interface XzFilter {
155
152
  properties: Uint8Array;
156
153
  }
157
154
 
158
- function deltaDecode(bytes: Uint8Array, distance: number): void {
155
+ /** Undo the delta filter (shared by XZ and 7z) in place. */
156
+ export function deltaDecode(bytes: Uint8Array, distance: number): void {
159
157
  const history = new Uint8Array(256);
160
158
  let position = 0;
161
159
  for (let index = 0; index < bytes.byteLength; index++) {
@@ -166,7 +164,8 @@ function deltaDecode(bytes: Uint8Array, distance: number): void {
166
164
  }
167
165
  }
168
166
 
169
- function x86Decode(bytes: Uint8Array, startOffset: number): void {
167
+ /** Undo the x86 BCJ filter (shared by XZ and 7z) in place; `startOffset` is the stream position of `bytes[0]`. */
168
+ export function x86Decode(bytes: Uint8Array, startOffset: number): void {
170
169
  const maskToBitNumber = [0, 1, 2, 2, 3] as const;
171
170
  let previousMask = 0;
172
171
  let previousPosition = 0xfffffffb;
@@ -267,7 +266,7 @@ function sparcDecode(bytes: Uint8Array, startOffset: number): void {
267
266
  )
268
267
  )
269
268
  continue;
270
- const source = (read32BE(bytes, index) << 2) >>> 0;
269
+ const source = (readUInt32BE(bytes, index) << 2) >>> 0;
271
270
  let destination = (source - startOffset - index) >>> 2;
272
271
  destination = (((0 - ((destination >>> 22) & 1)) << 22) & 0x3fffffff) | (destination & 0x3fffff) | 0x40000000;
273
272
  write32BE(bytes, index, destination >>> 0);
@@ -356,7 +355,7 @@ function riscvDecode(bytes: Uint8Array, startOffset: number): void {
356
355
  index += 2;
357
356
  continue;
358
357
  }
359
- const address = (read32BE(bytes, index + 4) - startOffset - index) >>> 0;
358
+ const address = (readUInt32BE(bytes, index + 4) - startOffset - index) >>> 0;
360
359
  instruction2 = ((instruction >>> 12) | (address << 20)) >>> 0;
361
360
  instruction = (0x17 | (register << 7) | ((address + 0x800) & 0xfffff000)) >>> 0;
362
361
  }