@linxiraos/pi-utils 1.4.6 → 1.4.9

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
@@ -1,8 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [1.4.6] - 2026-10-28
3
+ ## [1.4.9] - 2026-11-03
4
4
 
5
- - Released as part of Zeta 1.4.6; no package-specific user-visible changes.
5
+ - Synced with upstream OMP v18.16.0; no package-specific user-visible changes.
6
+
7
+ ## [1.4.7] - 2026-10-30
8
+
9
+ - Synced with upstream OMP v18.14.0; no package-specific user-visible changes.
6
10
 
7
11
  ## [1.4.5] - 2026-10-28
8
12
 
package/README.md CHANGED
@@ -29,7 +29,7 @@ Import from the root barrel or per-module subpaths (`@linxiraos/pi-utils/<module
29
29
  bun add @linxiraos/pi-utils
30
30
  ```
31
31
 
32
- Ships TypeScript source directly (no build step); requires Bun ≥ 1.3.14.
32
+ Ships TypeScript source directly (no build step); requires Bun ≥ 1.4.4.
33
33
 
34
34
  ## References
35
35
 
@@ -16,7 +16,8 @@ export declare class AbortError extends Error {
16
16
  export declare function abortableSource<T>(stream: ReadableStream<T>, signal?: AbortSignal): AsyncGenerator<T>;
17
17
  /**
18
18
  * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
19
- * execution, the promise is rejected with a standard error.
19
+ * execution, the promise is rejected with a standard error. A promise passed directly stays
20
+ * observed, so its later rejection is never unhandled.
20
21
  *
21
22
  * @param signal - Optional AbortSignal to cancel the operation
22
23
  * @param pr - Function returning a promise to run
@@ -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. */
@@ -16,6 +16,11 @@ export declare const MAX_TIMER_DELAY_MS = 2147483647;
16
16
  * the unelapsed duration instead of shortening the requested sleep.
17
17
  */
18
18
  export declare function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void>;
19
+ /**
20
+ * Wait for a wall-clock deadline, including time spent suspended. Some platforms
21
+ * exclude suspend from relative timers, so recheck at least once per second.
22
+ */
23
+ export declare function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void>;
19
24
  /**
20
25
  * Wrap a promise with a timeout and optional abort signal.
21
26
  * Rejects with the given error or a new error containing the given message if
@@ -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>;
@@ -99,6 +99,8 @@ export interface OKLCH {
99
99
  * hues, so carrying them between colors preserves the palette's "weight".
100
100
  */
101
101
  export declare function hexToOklch(hex: string): OKLCH;
102
+ /** Perceptual distance between two hex colors: Euclidean OKLab, 0 when identical, 1 from black to white. */
103
+ export declare function oklabDistance(a: string, b: string): number;
102
104
  /**
103
105
  * The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
104
106
  * hue reaches its maximum chroma inside sRGB.
@@ -16,6 +16,12 @@ declare function getLockPath(filePath: string): string;
16
16
  declare function tryAcquireLock(lockPath: string): NativeFileLock | null;
17
17
  /** Acquire an exclusive lease without waiting; `null` while another holder owns it. */
18
18
  export declare function tryAcquireFileLock(filePath: string): FileLockHandle | null;
19
+ /**
20
+ * Whether any holder, including this process, owns the lease, observed without
21
+ * taking it, so a concurrent `tryAcquireFileLock` never meets the probe.
22
+ * Throws when the lock cannot be probed.
23
+ */
24
+ export declare function isFileLockHeld(filePath: string): boolean;
19
25
  /** Acquire an exclusive lease; callers must release it when their operation ends. */
20
26
  export declare function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<FileLockHandle>;
21
27
  /** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
@@ -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";
@@ -10,7 +11,6 @@ export * from "./file-lock.js";
10
11
  export * from "./format.js";
11
12
  export * from "./frontmatter.js";
12
13
  export * from "./fs-error.js";
13
- export * from "./fs-open.js";
14
14
  export * from "./incoming-json.js";
15
15
  export * from "./json.js";
16
16
  export * from "./json-parse.js";
@@ -24,6 +24,7 @@ export * from "./path.js";
24
24
  export * from "./path-tree.js";
25
25
  export * from "./ports.js";
26
26
  export * from "./peek-file.js";
27
+ export * from "./pid.js";
27
28
  export * as postmortem from "./postmortem.js";
28
29
  export * from "./process-name.js";
29
30
  export * as procmgr from "./procmgr.js";
@@ -11,6 +11,20 @@ export interface LogEvent {
11
11
  export type LogSink = (event: LogEvent) => void;
12
12
  /** Register an out-of-band log sink and return a disposer. */
13
13
  export declare function registerLogSink(sink: LogSink): () => void;
14
+ /**
15
+ * Run `fn` with `context`'s fields on every record logged while it runs,
16
+ * including from the promises, timers and callbacks it starts. A nested call
17
+ * replaces the enclosing context, as a subagent's session replaces its
18
+ * parent's; a field passed to the log call itself wins. Fields are read as each
19
+ * record is written, so a getter stays current.
20
+ */
21
+ export declare function withContext<T>(context: Readonly<Record<string, unknown>>, fn: () => T): T;
22
+ /**
23
+ * Run `fn` outside any {@link withContext}: for process-wide work started on
24
+ * behalf of one caller, such as a shared server's read loop, whose later
25
+ * records belong to no single caller.
26
+ */
27
+ export declare function withoutContext<T>(fn: () => T): T;
14
28
  /**
15
29
  * Write buffered file records to disk now. The file transport batches records
16
30
  * (one write per second or per 64 KiB) and writes `warn`/`error` records
@@ -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;
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * OMP installs on the same machine use 30141/30142/3847, and common developer
5
5
  * tools squat on 3000/8080/8787/3847. Zeta therefore owns a dedicated block:
6
- * three uncommon ports, at least two apart, so a side-by-side OMP install and
7
- * unrelated services cannot take the gateway or the stats dashboard away.
6
+ * uncommon ports, at least two apart, so a side-by-side OMP install and
7
+ * unrelated services cannot take the gateway, stats dashboard, or browser
8
+ * relay away.
8
9
  *
9
10
  * Every default in the repository derives from these constants. The standalone
10
11
  * Electron shell (`desktop/src/main.ts`, not a Bun workspace package) carries
@@ -13,3 +14,4 @@
13
14
  export declare const ZETA_WEB_PORT = 43141;
14
15
  export declare const ZETA_GATEWAY_PORT = 43143;
15
16
  export declare const ZETA_STATS_PORT = 43147;
17
+ export declare const ZETA_BROWSER_RELAY_PORT = 43153;
@@ -178,6 +178,8 @@ export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintPro
178
178
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
179
179
  */
180
180
  export declare function fatal(error: unknown): Promise<never>;
181
+ /** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
182
+ export declare function installProcessHandlers(): void;
181
183
  /** Controls when a registered cleanup callback participates in cleanup passes. */
182
184
  export interface CleanupRegistrationOptions {
183
185
  /**
@@ -201,8 +203,7 @@ export interface CleanupRegistrationOptions {
201
203
  waitMessage?: string;
202
204
  }
203
205
  /**
204
- * Registers a cleanup callback for shutdown, signals, fatal errors, and
205
- * repeatable manual cleanup passes.
206
+ * Registers a cleanup callback for manual passes and, when installed, process shutdown.
206
207
  *
207
208
  * Registrations persist across keep-alive {@link cleanup} passes and run at
208
209
  * most once per pass. Set `exitOnly` for resources the continuing process still
@@ -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": "@linxiraos/pi-utils",
3
- "version": "1.4.6",
3
+ "version": "1.4.9",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,12 +54,13 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.4.6"
57
+ "@linxiraos/pi-natives": "1.4.9",
58
+ "@linxiraos/pi-omptype": "1.4.9"
58
59
  },
59
60
  "devDependencies": {
60
- "@types/bun": "1.4.3"
61
+ "@types/bun": "1.4.4"
61
62
  },
62
63
  "engines": {
63
- "bun": ">=1.3.14"
64
+ "bun": ">=1.4.4"
64
65
  }
65
66
  }
package/src/abortable.ts CHANGED
@@ -60,7 +60,8 @@ export async function* abortableSource<T>(stream: ReadableStream<T>, signal?: Ab
60
60
 
61
61
  /**
62
62
  * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
63
- * execution, the promise is rejected with a standard error.
63
+ * execution, the promise is rejected with a standard error. A promise passed directly stays
64
+ * observed, so its later rejection is never unhandled.
64
65
  *
65
66
  * @param signal - Optional AbortSignal to cancel the operation
66
67
  * @param pr - Function returning a promise to run
@@ -71,7 +72,10 @@ export function untilAborted<T>(
71
72
  pr: Promise<T> | (() => Promise<T>),
72
73
  ): Promise<T> {
73
74
  if (!signal) return typeof pr === "function" ? pr() : pr;
74
- if (signal.aborted) return Promise.reject(new AbortError(signal));
75
+ if (signal.aborted) {
76
+ if (typeof pr !== "function") void pr.catch(() => {});
77
+ return Promise.reject(new AbortError(signal));
78
+ }
75
79
 
76
80
  const { promise, resolve, reject } = Promise.withResolvers<T>();
77
81
  const onAbort = () => reject(new AbortError(signal));
package/src/acp/schema.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Type, type } from "@linxiraos/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 });