pi-codex-tools 0.2.4 → 0.3.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 +12 -0
- package/README.md +24 -3
- package/SECURITY.md +5 -1
- package/extensions/index.ts +3 -2
- package/package.json +3 -10
- package/src/apply-patch.ts +103 -278
- package/native/addon.c +0 -161
- package/prebuilds/darwin-arm64/pi-codex-tools.node +0 -0
- package/prebuilds/darwin-x64/pi-codex-tools.node +0 -0
- package/src/native.ts +0 -40
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-09-18
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Follow symlinked files and parents with Pi-style filesystem access. Delete removes a symlink itself; move preserves its source referent.
|
|
14
|
+
- Replace the platform-gated no-follow implementation with Node filesystem APIs and remove the macOS native binding and its dependencies.
|
|
15
|
+
- Share preflight content and deduplicate mutation locks across symlink aliases while retaining patch limits and sequential execution.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- GPT-6 Astra integration coverage for grammar serialization, streamed raw patch execution through symlinked paths, and custom-tool result replay on both Pi Responses transports.
|
|
20
|
+
|
|
9
21
|
## [0.2.4] - 2026-09-11
|
|
10
22
|
|
|
11
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
|
|
|
6
6
|
|
|
7
7
|
- **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
|
|
8
8
|
- **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
|
|
9
|
-
- **
|
|
9
|
+
- **Pi-style filesystem access** — accepts relative or absolute paths and follows symlinked files and directories, including macOS `/tmp`. Uses Node filesystem APIs without a native binding or platform gate.
|
|
10
|
+
- **Validated patches** — limits patches to 1 MiB and target-file reads to 64 MiB, preflights all hunks, and serializes writes with Pi's mutation queue.
|
|
10
11
|
- **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
|
|
11
12
|
- **Sequential patch calls** — the extension marks patch execution sequential while leaving provider-side parallel tool calls enabled.
|
|
12
13
|
- **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
|
|
@@ -25,7 +26,7 @@ pi -e /path/to/pi-mono/packages/pi-codex-tools
|
|
|
25
26
|
|
|
26
27
|
## Scope decisions
|
|
27
28
|
|
|
28
|
-
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models.
|
|
29
|
+
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Filesystem access uses the local user's permissions, like native Pi tools; it is not a sandbox. `apply_patch` requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
|
|
29
30
|
|
|
30
31
|
| Codex surface | Decision |
|
|
31
32
|
| --- | --- |
|
|
@@ -40,11 +41,31 @@ These choices are based on the Codex tool specifications in `codex-rs/core/src/t
|
|
|
40
41
|
|
|
41
42
|
## Compatibility notes
|
|
42
43
|
|
|
44
|
+
### GPT-6 Astra
|
|
45
|
+
|
|
46
|
+
Pi 0.85.1's model catalog advertises grammar-tool support for `gpt-6-astra` on both `openai-responses` and `openai-codex-responses`. The extension uses that capability directly, with no model-name allowlist or JSON wrapper. Tests cover the pinned Pi transports, streamed raw calls, execution, and result replay using mocked HTTP responses; they do not certify live account access.
|
|
47
|
+
|
|
48
|
+
The [GPT-6 guide](https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra) also describes async tool calls, mid-turn steering, and reasoning updates. Those belong to the provider/session runtime and are not enabled by this extension. Patch execution remains sequential; provider-side parallel tool calling remains enabled.
|
|
49
|
+
|
|
43
50
|
With an updated `pi-codex-compaction` installed, the package supplies its owned
|
|
44
51
|
grammar metadata through Pi's public event bus. This keeps raw `apply_patch`
|
|
45
52
|
calls and results intact in direct Codex compaction requests, including Astra.
|
|
46
53
|
No private Pi registry is patched.
|
|
47
54
|
|
|
55
|
+
### Filesystem behavior
|
|
56
|
+
|
|
57
|
+
- Add and update follow file symlinks and preserve the links. Add can create the target of a dangling symlink.
|
|
58
|
+
- Symlinked parents work for add, update, delete, and move, including paths outside the current directory.
|
|
59
|
+
- Delete removes the named entry. Deleting a symlink leaves its target unchanged.
|
|
60
|
+
- Move writes the updated content to the destination, then removes the source entry. A symlink destination is followed; a symlink source is removed without deleting its target.
|
|
61
|
+
- Preflight shares virtual content across symlink aliases and rejects moves onto the same resolved target.
|
|
62
|
+
- Symlink loops are rejected.
|
|
63
|
+
- Patches operate on regular text files, not directories, devices, sockets, or pipes.
|
|
64
|
+
|
|
65
|
+
Like native Pi tools, normal path-based I/O does not protect against another process replacing a path during execution. Preflight is not a transaction; an I/O failure can leave earlier files changed. See [SECURITY.md](./SECURITY.md).
|
|
66
|
+
|
|
67
|
+
### Text format
|
|
68
|
+
|
|
48
69
|
`apply_patch` is line-oriented rather than byte-oriented:
|
|
49
70
|
|
|
50
71
|
- `*** Add File` requires at least one `+` line and writes a trailing newline. A `+`-only hunk creates a one-newline file, not a zero-byte file.
|
|
@@ -54,7 +75,7 @@ No private Pi registry is patched.
|
|
|
54
75
|
|
|
55
76
|
These behaviors intentionally match Codex `apply_patch`.
|
|
56
77
|
|
|
57
|
-
The provider contract is runtime-specific: use Pi 0.83.0 or newer for OpenAI grammar-tool support. For a manual smoke test, start Pi with this extension and a model that advertises `supportsOpenAIGrammarTools`, then
|
|
78
|
+
The provider contract is runtime-specific: use Pi 0.83.0 or newer for OpenAI grammar-tool support. For a manual smoke test, start Pi with this extension and a model that advertises `supportsOpenAIGrammarTools`, then ask it to create and update a disposable file through a symlinked directory (on macOS, `/tmp` is suitable). Verify that changes appear as raw `apply_patch` calls, the referent changes, and the symlink remains. Switch to an unsupported model and verify that the original file tools return.
|
|
58
79
|
|
|
59
80
|
## Development
|
|
60
81
|
|
package/SECURITY.md
CHANGED
|
@@ -19,7 +19,11 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
|
|
21
21
|
|
|
22
|
-
`apply_patch` does not access the network or credential APIs. Like Pi's native `edit` and `write` tools, it accepts relative or absolute paths and can modify files outside the current working directory with the local user's permissions. It can read credential-containing files when a patch targets them.
|
|
22
|
+
`apply_patch` does not access the network or credential APIs. Like Pi's native `edit` and `write` tools, it accepts relative or absolute paths, follows symlinks for reads/writes, and can modify files outside the current working directory with the local user's permissions. It can read credential-containing files when a patch targets them. Deleting a symlink removes the link, not its referent; moving a symlink source copies its referent's updated content and removes the source link.
|
|
23
|
+
|
|
24
|
+
The tool uses Node filesystem APIs without a platform-specific native binding. This deliberately replaces the previous no-follow policy with Pi-style filesystem access. Path canonicalization is used for preflight identity and queue keys, not as a security boundary. There is no workspace confinement or protection against another process swapping path components between resolution and I/O. Use an OS-level sandbox or restricted user account when filesystem isolation is required.
|
|
25
|
+
|
|
26
|
+
Patch input is limited to 1 MiB, hunk count to 1,000, and target-file reads to 64 MiB. Preflight rejects non-file write targets and checks all hunk matches before mutation. Symlink aliases share virtual file state and deduplicated mutation queues. Queues serialize cooperating Pi mutations and remain held until pending I/O settles; they do not lock out other processes. A failure or cancellation during a multi-file write can still leave earlier files changed. Use version control and review the resulting diff.
|
|
23
27
|
|
|
24
28
|
The package strips untrusted C0/C1 control bytes and terminal escape sequences from `apply_patch` preview text and paths before handing them to the TUI. The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
|
|
25
29
|
|
package/extensions/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Container, Text } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
5
|
-
import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES
|
|
5
|
+
import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES } from "../src/apply-patch.js";
|
|
6
6
|
import { createFreeformInputSchema, createOpenAILarkSampling, type OpenAIGrammarSampling } from "../src/grammar.js";
|
|
7
7
|
import { supportsOpenAIGrammarTools } from "../src/model-support.js";
|
|
8
8
|
import { formatApplyPatchCallText, formatApplyPatchResultText } from "../src/patch-preview.js";
|
|
@@ -45,6 +45,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
45
45
|
"Use apply_patch for file changes when it is available.",
|
|
46
46
|
"Send the patch body directly; do not wrap it in JSON or add a shell heredoc.",
|
|
47
47
|
`Patch paths may be relative to the current working directory or absolute, and patches are limited to ${MAX_PATCH_BYTES} bytes.`,
|
|
48
|
+
"apply_patch follows symlinks for file writes; deleting a symlink removes the link, not its target.",
|
|
48
49
|
],
|
|
49
50
|
parameters: APPLY_PATCH_PARAMETERS,
|
|
50
51
|
constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
|
|
@@ -108,7 +109,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
108
109
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
109
110
|
|
|
110
111
|
const active = new Set(pi.getActiveTools());
|
|
111
|
-
if (supportsOpenAIGrammarTools(ctx.model)
|
|
112
|
+
if (supportsOpenAIGrammarTools(ctx.model)) {
|
|
112
113
|
if (replacedToolsWasActive === undefined) {
|
|
113
114
|
replacedToolsWasActive = {
|
|
114
115
|
edit: active.has(EDIT),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -35,8 +35,6 @@
|
|
|
35
35
|
"index.ts",
|
|
36
36
|
"extensions",
|
|
37
37
|
"src",
|
|
38
|
-
"native",
|
|
39
|
-
"prebuilds",
|
|
40
38
|
"README.md",
|
|
41
39
|
"NOTICE",
|
|
42
40
|
"LICENSE",
|
|
@@ -49,8 +47,6 @@
|
|
|
49
47
|
"check": "tsc --noEmit",
|
|
50
48
|
"typecheck": "tsc --noEmit",
|
|
51
49
|
"test": "node --import tsx --test tests/*.test.mjs",
|
|
52
|
-
"build:native": "node-gyp configure build",
|
|
53
|
-
"prebuild:native": "prebuildify --napi --arch arm64 && prebuildify --napi --arch x64",
|
|
54
50
|
"pack:dry-run": "npm pack --dry-run"
|
|
55
51
|
},
|
|
56
52
|
"peerDependencies": {
|
|
@@ -64,7 +60,6 @@
|
|
|
64
60
|
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
65
61
|
"@earendil-works/pi-tui": "^0.85.1",
|
|
66
62
|
"@types/node": "^26.2.0",
|
|
67
|
-
"prebuildify": "^6.0.1",
|
|
68
63
|
"tsx": "^4.23.5",
|
|
69
64
|
"typebox": "^1.3.10",
|
|
70
65
|
"typescript": "^7.0.2"
|
|
@@ -76,8 +71,6 @@
|
|
|
76
71
|
"node": ">=20.6.0"
|
|
77
72
|
},
|
|
78
73
|
"dependencies": {
|
|
79
|
-
"@mocito/install-telemetry": "0.1.1"
|
|
80
|
-
|
|
81
|
-
},
|
|
82
|
-
"gypfile": false
|
|
74
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
75
|
+
}
|
|
83
76
|
}
|
package/src/apply-patch.ts
CHANGED
|
@@ -1,89 +1,13 @@
|
|
|
1
1
|
// Adapted from OpenAI Codex apply-patch grammar/parser behavior; see NOTICE.
|
|
2
|
-
import { constants
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, readlink, realpath, open, mkdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, parse, join, sep } from "node:path";
|
|
6
5
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { getOpenAtBindings } from "./native.js";
|
|
8
6
|
|
|
9
7
|
export const MAX_PATCH_BYTES = 1_048_576;
|
|
10
8
|
export const MAX_PATCH_HUNKS = 1_000;
|
|
11
9
|
export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
|
|
12
10
|
|
|
13
|
-
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
14
|
-
const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : undefined;
|
|
15
|
-
const OPENAT_BINDINGS = getOpenAtBindings();
|
|
16
|
-
const BASE_SECURE_FILESYSTEM_SUPPORTED =
|
|
17
|
-
(process.platform === "linux" && SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0) ||
|
|
18
|
-
(process.platform === "darwin" && OPENAT_BINDINGS !== null && O_NOFOLLOW !== 0);
|
|
19
|
-
let secureFilesystemSupportedOverride: boolean | undefined;
|
|
20
|
-
|
|
21
|
-
/** Whether apply_patch can safely execute against this platform's filesystem. */
|
|
22
|
-
export function secureFilesystemSupported(): boolean {
|
|
23
|
-
return secureFilesystemSupportedOverride ?? BASE_SECURE_FILESYSTEM_SUPPORTED;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** @internal Force the support flag so the activation path can be tested on any host platform. */
|
|
27
|
-
export function setSecureFilesystemSupportedForTest(value: boolean | undefined): void {
|
|
28
|
-
secureFilesystemSupportedOverride = value;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const openFd = (path: string, flags: number, mode: number): Promise<number> =>
|
|
32
|
-
new Promise((resolveP, rejectP) => openCb(path, flags, mode, (error, fd) => (error ? rejectP(error) : resolveP(fd))));
|
|
33
|
-
const fstatFd = (fd: number): Promise<Stats> =>
|
|
34
|
-
new Promise((resolveP, rejectP) => fstatCb(fd, (error, stats) => (error ? rejectP(error) : resolveP(stats))));
|
|
35
|
-
const readFd = (fd: number, buffer: Buffer, offset: number, length: number, position: number | null): Promise<number> =>
|
|
36
|
-
new Promise((resolveP, rejectP) => readCb(fd, buffer, offset, length, position, (error, bytesRead) => (error ? rejectP(error) : resolveP(bytesRead))));
|
|
37
|
-
const writeFd = (fd: number, buffer: Buffer, offset: number, length: number, position: number | null): Promise<number> =>
|
|
38
|
-
new Promise((resolveP, rejectP) => writeCb(fd, buffer, offset, length, position, (error, written) => (error ? rejectP(error) : resolveP(written))));
|
|
39
|
-
const closeFd = (fd: number): Promise<void> =>
|
|
40
|
-
new Promise((resolveP, rejectP) => closeCb(fd, (error) => (error ? rejectP(error) : resolveP())));
|
|
41
|
-
const mkdirPath = (path: string, mode: number): Promise<void> =>
|
|
42
|
-
new Promise((resolveP, rejectP) => mkdirCb(path, mode, (error) => (error ? rejectP(error) : resolveP())));
|
|
43
|
-
const unlinkPath = (path: string): Promise<void> =>
|
|
44
|
-
new Promise((resolveP, rejectP) => unlinkCb(path, (error) => (error ? rejectP(error) : resolveP())));
|
|
45
|
-
|
|
46
|
-
// Open `child` relative to a trusted open directory descriptor, never following the final component.
|
|
47
|
-
async function openChildRelative(parentFd: number, child: string, flags: number, mode: number): Promise<number> {
|
|
48
|
-
if (OPENAT_BINDINGS) return OPENAT_BINDINGS.openat(parentFd, child, flags, mode);
|
|
49
|
-
return openFd(join(SECURE_FD_DIRECTORY as string, String(parentFd), child), flags, mode);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function mkdirChildRelative(parentFd: number, child: string, mode: number): Promise<void> {
|
|
53
|
-
if (OPENAT_BINDINGS) {
|
|
54
|
-
OPENAT_BINDINGS.mkdirat(parentFd, child, mode);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
await mkdirPath(join(SECURE_FD_DIRECTORY as string, String(parentFd), child), mode);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function unlinkChildRelative(parentFd: number, child: string): Promise<void> {
|
|
61
|
-
if (OPENAT_BINDINGS) {
|
|
62
|
-
OPENAT_BINDINGS.unlinkat(parentFd, child);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
await unlinkPath(join(SECURE_FD_DIRECTORY as string, String(parentFd), child));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// No-follow stat of `child` relative to a trusted descriptor, without opening it
|
|
69
|
-
// (so unreadable files can still be inspected for symlink/directory rejection).
|
|
70
|
-
async function lstatChildRelative(parentFd: number, child: string): Promise<{ isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }> {
|
|
71
|
-
if (OPENAT_BINDINGS) return OPENAT_BINDINGS.lstatAt(parentFd, child);
|
|
72
|
-
const stats = await lstat(join(SECURE_FD_DIRECTORY as string, String(parentFd), child));
|
|
73
|
-
return { isFile: stats.isFile(), isDirectory: stats.isDirectory(), isSymbolicLink: stats.isSymbolicLink() };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function writeAllFd(fd: number, data: string): Promise<void> {
|
|
77
|
-
const buffer = Buffer.from(data, "utf8");
|
|
78
|
-
let written = 0;
|
|
79
|
-
while (written < buffer.length) {
|
|
80
|
-
written += await writeFd(fd, buffer, written, buffer.length - written, written);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
84
|
-
const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
|
|
85
|
-
const SECURE_UPDATE_FLAGS = constants.O_WRONLY | O_NOFOLLOW | constants.O_TRUNC | (constants.O_NONBLOCK ?? 0);
|
|
86
|
-
const SECURE_CREATE_FLAGS = SECURE_UPDATE_FLAGS | constants.O_CREAT;
|
|
87
11
|
const FILE_READ_CHUNK_BYTES = 64 * 1024;
|
|
88
12
|
|
|
89
13
|
export const APPLY_PATCH_GRAMMAR = `start: begin_patch hunk+ end_patch
|
|
@@ -292,14 +216,12 @@ type PlannedOperation =
|
|
|
292
216
|
| { kind: "delete"; path: string; displayPath: string }
|
|
293
217
|
| { kind: "update"; path: string; displayPath: string; moveTo?: string; moveDisplayPath?: string; chunkGroups: UpdateChunk[][]; content: string };
|
|
294
218
|
|
|
295
|
-
type
|
|
296
|
-
type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
|
|
219
|
+
type VirtualFile = { exists: boolean; isDirectory: boolean; isFile: boolean; isSymbolicLink?: boolean; content?: string };
|
|
297
220
|
|
|
298
221
|
export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
299
|
-
requireSecureFilesystem();
|
|
300
222
|
const hunks = parseApplyPatch(input);
|
|
223
|
+
throwIfAborted(options.signal);
|
|
301
224
|
const cwd = await realpath(resolve(options.cwd));
|
|
302
|
-
const root = sep;
|
|
303
225
|
const lockPaths = hunks.flatMap((hunk) => {
|
|
304
226
|
const paths = [resolvePatchPath(hunk.path, cwd)];
|
|
305
227
|
if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, cwd));
|
|
@@ -307,71 +229,61 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
|
|
|
307
229
|
});
|
|
308
230
|
|
|
309
231
|
return withMutationLocks(lockPaths, async () => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
232
|
+
const operations = await planOperations(hunks, cwd, options.signal);
|
|
233
|
+
throwIfAborted(options.signal);
|
|
234
|
+
// Preflight catches parse/match errors before writes; I/O failures can still leave a partial patch.
|
|
235
|
+
for (const operation of operations) {
|
|
313
236
|
throwIfAborted(options.signal);
|
|
314
|
-
|
|
315
|
-
|
|
237
|
+
if (operation.kind === "add") {
|
|
238
|
+
await writeTargetFile(operation.path, operation.content, options.signal);
|
|
239
|
+
} else if (operation.kind === "delete") {
|
|
240
|
+
await unlink(operation.path);
|
|
241
|
+
} else if (operation.moveTo) {
|
|
242
|
+
await writeTargetFile(operation.moveTo, operation.content, options.signal);
|
|
316
243
|
throwIfAborted(options.signal);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await removeSecureFile(rootFd, root, operation.path, options.signal);
|
|
321
|
-
} else if (operation.moveTo) {
|
|
322
|
-
await writeSecureFile(rootFd, root, operation.moveTo, operation.content, true, options.signal);
|
|
323
|
-
await removeSecureFile(rootFd, root, operation.path, options.signal);
|
|
324
|
-
} else {
|
|
325
|
-
await writeSecureFile(rootFd, root, operation.path, operation.content, false, options.signal);
|
|
326
|
-
}
|
|
244
|
+
await unlink(operation.path);
|
|
245
|
+
} else {
|
|
246
|
+
await writeTargetFile(operation.path, operation.content, options.signal);
|
|
327
247
|
}
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
changes: operations.map((operation) => ({
|
|
331
|
-
kind: operation.kind === "add" ? "added" : operation.kind === "delete" ? "deleted" : "updated",
|
|
332
|
-
path: operation.displayPath,
|
|
333
|
-
...(operation.kind === "update" && operation.moveDisplayPath ? { moveTo: operation.moveDisplayPath } : {}),
|
|
334
|
-
})),
|
|
335
|
-
};
|
|
336
|
-
} finally {
|
|
337
|
-
await closeFd(rootFd);
|
|
338
248
|
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
changes: operations.map((operation) => ({
|
|
252
|
+
kind: operation.kind === "add" ? "added" : operation.kind === "delete" ? "deleted" : "updated",
|
|
253
|
+
path: operation.displayPath,
|
|
254
|
+
...(operation.kind === "update" && operation.moveDisplayPath ? { moveTo: operation.moveDisplayPath } : {}),
|
|
255
|
+
})),
|
|
256
|
+
};
|
|
339
257
|
});
|
|
340
258
|
}
|
|
341
259
|
|
|
342
260
|
async function planOperations(
|
|
343
261
|
hunks: ApplyPatchHunk[],
|
|
344
262
|
cwd: string,
|
|
345
|
-
root: string,
|
|
346
|
-
rootFd: number,
|
|
347
263
|
signal?: AbortSignal,
|
|
348
264
|
): Promise<PlannedOperation[]> {
|
|
349
265
|
const operations: PlannedOperation[] = [];
|
|
350
266
|
const virtualFiles = new Map<string, VirtualFile>();
|
|
351
267
|
|
|
352
|
-
const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
353
|
-
const absolute = resolvePatchPath(rawPath, cwd);
|
|
268
|
+
const getVirtualFile = async (rawPath: string, followFinal = true): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
269
|
+
const absolute = await canonicalPath(resolvePatchPath(rawPath, cwd), followFinal, virtualFiles, signal);
|
|
354
270
|
const existing = virtualFiles.get(absolute);
|
|
355
|
-
if (existing) return { absolute, file: existing };
|
|
356
|
-
const
|
|
357
|
-
const file: VirtualFile = {
|
|
358
|
-
exists: safe.exists,
|
|
359
|
-
isDirectory: safe.isDirectory,
|
|
360
|
-
isFile: safe.isFile,
|
|
361
|
-
};
|
|
271
|
+
if (existing && !existing.isSymbolicLink) return { absolute, file: existing };
|
|
272
|
+
const file = await inspectPath(absolute);
|
|
362
273
|
virtualFiles.set(absolute, file);
|
|
363
274
|
return { absolute, file };
|
|
364
275
|
};
|
|
365
276
|
|
|
366
277
|
const getVirtualContent = async (absolute: string, file: VirtualFile): Promise<string> => {
|
|
367
278
|
if (!file.exists || !file.isFile) throw new Error(`Cannot read non-file '${absolute}'.`);
|
|
368
|
-
if (file.content === undefined) file.content = await
|
|
279
|
+
if (file.content === undefined) file.content = await readTargetFile(absolute, signal);
|
|
369
280
|
return file.content;
|
|
370
281
|
};
|
|
371
282
|
|
|
372
283
|
for (const hunk of hunks) {
|
|
373
284
|
throwIfAborted(signal);
|
|
374
|
-
const source = await getVirtualFile(hunk.path);
|
|
285
|
+
const source = await getVirtualFile(hunk.path, hunk.kind !== "delete");
|
|
286
|
+
const sourceDisplay = displayPath(cwd, resolvePatchPath(hunk.path, cwd), hunk.path);
|
|
375
287
|
|
|
376
288
|
if (hunk.kind === "add") {
|
|
377
289
|
if (source.file.isDirectory || (source.file.exists && !source.file.isFile)) {
|
|
@@ -384,7 +296,7 @@ async function planOperations(
|
|
|
384
296
|
operations.push({
|
|
385
297
|
kind: "add",
|
|
386
298
|
path: source.absolute,
|
|
387
|
-
displayPath:
|
|
299
|
+
displayPath: sourceDisplay,
|
|
388
300
|
content: hunk.content,
|
|
389
301
|
});
|
|
390
302
|
continue;
|
|
@@ -392,9 +304,10 @@ async function planOperations(
|
|
|
392
304
|
|
|
393
305
|
if (hunk.kind === "delete") {
|
|
394
306
|
if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
395
|
-
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
396
|
-
operations.push({ kind: "delete", path: source.absolute, displayPath:
|
|
307
|
+
if (!source.file.isSymbolicLink && (source.file.isDirectory || !source.file.isFile)) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
308
|
+
operations.push({ kind: "delete", path: source.absolute, displayPath: sourceDisplay });
|
|
397
309
|
source.file.exists = false;
|
|
310
|
+
source.file.isSymbolicLink = false;
|
|
398
311
|
source.file.content = undefined;
|
|
399
312
|
continue;
|
|
400
313
|
}
|
|
@@ -402,12 +315,10 @@ async function planOperations(
|
|
|
402
315
|
if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
403
316
|
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
404
317
|
const original = await getVirtualContent(source.absolute, source.file);
|
|
405
|
-
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, cwd) : undefined;
|
|
406
|
-
if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
407
|
-
|
|
408
318
|
let destination: { absolute: string; file: VirtualFile } | undefined;
|
|
409
|
-
if (moveTo) {
|
|
319
|
+
if (hunk.moveTo) {
|
|
410
320
|
destination = await getVirtualFile(hunk.moveTo!);
|
|
321
|
+
if (destination.absolute === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
411
322
|
if (destination.file.isDirectory || (destination.file.exists && !destination.file.isFile)) {
|
|
412
323
|
throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
|
|
413
324
|
}
|
|
@@ -415,17 +326,21 @@ async function planOperations(
|
|
|
415
326
|
|
|
416
327
|
const content = applyUpdateContent(original, hunk.chunks, hunk.path);
|
|
417
328
|
if (destination) {
|
|
329
|
+
// A move copies the referent's content, then unlinks the source entry.
|
|
330
|
+
// Moving a symlink must not delete its referent.
|
|
331
|
+
const sourceEntry = await getVirtualFile(hunk.path, false);
|
|
418
332
|
operations.push({
|
|
419
333
|
kind: "update",
|
|
420
|
-
path:
|
|
421
|
-
displayPath:
|
|
422
|
-
moveTo:
|
|
423
|
-
moveDisplayPath: displayPath(cwd, moveTo!, hunk.moveTo!),
|
|
334
|
+
path: sourceEntry.absolute,
|
|
335
|
+
displayPath: sourceDisplay,
|
|
336
|
+
moveTo: destination.absolute,
|
|
337
|
+
moveDisplayPath: displayPath(cwd, resolvePatchPath(hunk.moveTo!, cwd), hunk.moveTo!),
|
|
424
338
|
chunkGroups: [[...hunk.chunks]],
|
|
425
339
|
content,
|
|
426
340
|
});
|
|
427
|
-
|
|
428
|
-
|
|
341
|
+
sourceEntry.file.exists = false;
|
|
342
|
+
sourceEntry.file.isSymbolicLink = false;
|
|
343
|
+
sourceEntry.file.content = undefined;
|
|
429
344
|
destination.file.exists = true;
|
|
430
345
|
destination.file.isDirectory = false;
|
|
431
346
|
destination.file.isFile = true;
|
|
@@ -441,7 +356,7 @@ async function planOperations(
|
|
|
441
356
|
operations.push({
|
|
442
357
|
kind: "update",
|
|
443
358
|
path: source.absolute,
|
|
444
|
-
displayPath:
|
|
359
|
+
displayPath: sourceDisplay,
|
|
445
360
|
chunkGroups: [[...hunk.chunks]],
|
|
446
361
|
content,
|
|
447
362
|
});
|
|
@@ -456,131 +371,58 @@ function resolvePatchPath(rawPath: string, cwd: string): string {
|
|
|
456
371
|
return isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);
|
|
457
372
|
}
|
|
458
373
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
374
|
+
// Resolve aliases for preflight identity, including dangling links and missing
|
|
375
|
+
// descendants. This is not a filesystem sandbox; I/O uses normal Node semantics.
|
|
376
|
+
async function canonicalPath(
|
|
377
|
+
absolute: string,
|
|
378
|
+
followFinal = true,
|
|
379
|
+
virtualFiles = new Map<string, VirtualFile>(),
|
|
380
|
+
signal?: AbortSignal,
|
|
381
|
+
): Promise<string> {
|
|
382
|
+
let links = 0;
|
|
383
|
+
let current = parse(absolute).root;
|
|
384
|
+
let pending = relative(current, absolute).split(sep).filter(Boolean);
|
|
385
|
+
while (pending.length > 0) {
|
|
466
386
|
throwIfAborted(signal);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (current !== absolute && !stats.isDirectory()) {
|
|
473
|
-
throw new Error(`Parent path is not a directory: ${rawPath}`);
|
|
474
|
-
}
|
|
475
|
-
if (current === absolute) {
|
|
476
|
-
target = { exists: true, isDirectory: stats.isDirectory(), isFile: stats.isFile() };
|
|
477
|
-
}
|
|
478
|
-
} catch (error) {
|
|
479
|
-
if (!isMissingPathError(error)) throw error;
|
|
387
|
+
const component = pending.shift()!;
|
|
388
|
+
if (component === ".") continue;
|
|
389
|
+
if (component === "..") {
|
|
390
|
+
current = dirname(current);
|
|
391
|
+
continue;
|
|
480
392
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function openSecureRoot(root: string, signal?: AbortSignal): Promise<number> {
|
|
494
|
-
requireSecureFilesystem();
|
|
495
|
-
let current = await openFd(sep, SECURE_DIRECTORY_FLAGS, 0);
|
|
496
|
-
try {
|
|
497
|
-
for (const component of root.split(sep).filter(Boolean)) {
|
|
498
|
-
throwIfAborted(signal);
|
|
499
|
-
const next = await openSecureDirectoryChild(current, component);
|
|
500
|
-
await closeFd(current);
|
|
501
|
-
current = next;
|
|
393
|
+
current = join(current, component);
|
|
394
|
+
const file = virtualFiles.get(current) ?? await inspectPath(current);
|
|
395
|
+
if (file.isSymbolicLink && (followFinal || pending.length > 0)) {
|
|
396
|
+
if (++links > 40) throw new Error(`Too many symbolic links in patch path: ${absolute}`);
|
|
397
|
+
const target = await readlink(current);
|
|
398
|
+
const root = parse(target).root;
|
|
399
|
+
current = root || dirname(current);
|
|
400
|
+
// Do not normalize '..' in a link's stored target before following any
|
|
401
|
+
// earlier links: the OS applies it to the resolved directory.
|
|
402
|
+
pending = [...target.slice(root.length).split(sep).filter(Boolean), ...pending];
|
|
403
|
+
} else if (pending.length > 0 && file.exists && !file.isDirectory) {
|
|
404
|
+
throw new Error(`Parent path is not a directory: ${absolute}`);
|
|
502
405
|
}
|
|
503
|
-
return current;
|
|
504
|
-
} catch (error) {
|
|
505
|
-
await closeFd(current).catch(() => undefined);
|
|
506
|
-
throw error;
|
|
507
406
|
}
|
|
407
|
+
return current;
|
|
508
408
|
}
|
|
509
409
|
|
|
510
|
-
async function
|
|
511
|
-
const fd = await openChildRelative(parentFd, component, SECURE_DIRECTORY_FLAGS, 0);
|
|
410
|
+
async function inspectPath(absolute: string): Promise<VirtualFile> {
|
|
512
411
|
try {
|
|
513
|
-
|
|
514
|
-
return
|
|
412
|
+
const stats = await lstat(absolute);
|
|
413
|
+
return { exists: true, isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink() };
|
|
515
414
|
} catch (error) {
|
|
516
|
-
|
|
517
|
-
|
|
415
|
+
if (!isMissingPathError(error)) throw error;
|
|
416
|
+
return { exists: false, isDirectory: false, isFile: false };
|
|
518
417
|
}
|
|
519
418
|
}
|
|
520
419
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
async function openSecureParentDirectory(rootFd: number, root: string, absolute: string, createParents: boolean, signal?: AbortSignal): Promise<SecureParent> {
|
|
524
|
-
const parentPath = dirname(absolute);
|
|
525
|
-
const relativeParent = relative(root, parentPath);
|
|
526
|
-
const components = relativeParent ? relativeParent.split(sep) : [];
|
|
527
|
-
if (components.some((component) => !component || component === "." || component === "..")) {
|
|
528
|
-
throw new Error(`Patch path cannot be traversed securely: ${absolute}`);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
let current = rootFd;
|
|
532
|
-
let owned = false;
|
|
533
|
-
try {
|
|
534
|
-
for (const component of components) {
|
|
535
|
-
throwIfAborted(signal);
|
|
536
|
-
let next: number;
|
|
537
|
-
try {
|
|
538
|
-
next = await openSecureDirectoryChild(current, component);
|
|
539
|
-
} catch (error) {
|
|
540
|
-
if (!createParents || !isNoEntryError(error)) throw error;
|
|
541
|
-
try {
|
|
542
|
-
await mkdirChildRelative(current, component, 0o777);
|
|
543
|
-
} catch (mkdirError) {
|
|
544
|
-
if (!isAlreadyExistsError(mkdirError)) throw mkdirError;
|
|
545
|
-
}
|
|
546
|
-
next = await openSecureDirectoryChild(current, component);
|
|
547
|
-
}
|
|
548
|
-
if (owned) await closeFd(current);
|
|
549
|
-
current = next;
|
|
550
|
-
owned = true;
|
|
551
|
-
}
|
|
552
|
-
return { handle: current, owned };
|
|
553
|
-
} catch (error) {
|
|
554
|
-
if (owned) await closeFd(current).catch(() => undefined);
|
|
555
|
-
throw error;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
async function withSecureFile<T>(
|
|
560
|
-
rootFd: number,
|
|
561
|
-
root: string,
|
|
562
|
-
absolute: string,
|
|
563
|
-
flags: number,
|
|
564
|
-
createParents: boolean,
|
|
565
|
-
callback: (fd: number, size: number) => Promise<T>,
|
|
566
|
-
signal?: AbortSignal,
|
|
567
|
-
): Promise<T> {
|
|
568
|
-
const parent = await openSecureParentDirectory(rootFd, root, absolute, createParents, signal);
|
|
569
|
-
let fd: number | undefined;
|
|
420
|
+
async function readTargetFile(absolute: string, signal?: AbortSignal): Promise<string> {
|
|
421
|
+
const file = await open(absolute, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
570
422
|
try {
|
|
571
|
-
|
|
572
|
-
const stats = await fstatFd(fd);
|
|
423
|
+
const stats = await file.stat();
|
|
573
424
|
if (!stats.isFile()) throw new Error(`Patch target is not a regular file: ${absolute}`);
|
|
574
|
-
|
|
575
|
-
} finally {
|
|
576
|
-
if (fd !== undefined) await closeFd(fd).catch(() => undefined);
|
|
577
|
-
if (parent.owned) await closeFd(parent.handle).catch(() => undefined);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async function readSecureFile(rootFd: number, root: string, absolute: string, signal?: AbortSignal): Promise<string> {
|
|
582
|
-
return withSecureFile(rootFd, root, absolute, SECURE_READ_FLAGS, false, async (fd, size) => {
|
|
583
|
-
if (size > MAX_TARGET_FILE_BYTES) {
|
|
425
|
+
if (stats.size > MAX_TARGET_FILE_BYTES) {
|
|
584
426
|
throw new Error(`Patch target exceeds the ${MAX_TARGET_FILE_BYTES}-byte limit: ${absolute}`);
|
|
585
427
|
}
|
|
586
428
|
|
|
@@ -589,7 +431,7 @@ async function readSecureFile(rootFd: number, root: string, absolute: string, si
|
|
|
589
431
|
while (true) {
|
|
590
432
|
throwIfAborted(signal);
|
|
591
433
|
const buffer = Buffer.alloc(Math.min(FILE_READ_CHUNK_BYTES, MAX_TARGET_FILE_BYTES + 1 - total));
|
|
592
|
-
const bytesRead = await
|
|
434
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
593
435
|
if (bytesRead === 0) break;
|
|
594
436
|
total += bytesRead;
|
|
595
437
|
chunks.push(buffer.subarray(0, bytesRead));
|
|
@@ -598,31 +440,20 @@ async function readSecureFile(rootFd: number, root: string, absolute: string, si
|
|
|
598
440
|
}
|
|
599
441
|
}
|
|
600
442
|
return Buffer.concat(chunks, total).toString("utf8");
|
|
601
|
-
}, signal);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
async function writeSecureFile(rootFd: number, root: string, absolute: string, content: string, createParents: boolean, signal?: AbortSignal): Promise<void> {
|
|
605
|
-
const flags = createParents ? SECURE_CREATE_FLAGS : SECURE_UPDATE_FLAGS;
|
|
606
|
-
await withSecureFile(rootFd, root, absolute, flags, createParents, async (fd) => {
|
|
607
|
-
await writeAllFd(fd, content);
|
|
608
|
-
}, signal);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function removeSecureFile(rootFd: number, root: string, absolute: string, signal?: AbortSignal): Promise<void> {
|
|
612
|
-
const parent = await openSecureParentDirectory(rootFd, root, absolute, false, signal);
|
|
613
|
-
try {
|
|
614
|
-
throwIfAborted(signal);
|
|
615
|
-
// No-follow stat without opening the target, so unreadable (mode-000) files can still be deleted;
|
|
616
|
-
// unlink only requires write permission on the parent directory, never on the file itself.
|
|
617
|
-
const stats = await lstatChildRelative(parent.handle, basename(absolute));
|
|
618
|
-
if (stats.isSymbolicLink) throw new Error(`Symlink paths are not allowed in apply_patch: ${absolute}`);
|
|
619
|
-
if (stats.isDirectory) throw new Error(`Cannot delete directory '${absolute}'.`);
|
|
620
|
-
await unlinkChildRelative(parent.handle, basename(absolute));
|
|
621
443
|
} finally {
|
|
622
|
-
|
|
444
|
+
await file.close();
|
|
623
445
|
}
|
|
624
446
|
}
|
|
625
447
|
|
|
448
|
+
async function writeTargetFile(absolute: string, content: string, signal?: AbortSignal): Promise<void> {
|
|
449
|
+
throwIfAborted(signal);
|
|
450
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
451
|
+
throwIfAborted(signal);
|
|
452
|
+
// Match Pi write: follow symlinks and retain the mutation lock until I/O settles.
|
|
453
|
+
await writeFile(absolute, content, "utf8");
|
|
454
|
+
throwIfAborted(signal);
|
|
455
|
+
}
|
|
456
|
+
|
|
626
457
|
function applyUpdateContent(original: string, chunks: UpdateChunk[], displayPath: string): string {
|
|
627
458
|
const bom = original.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
628
459
|
const body = bom ? original.slice(1) : original;
|
|
@@ -713,7 +544,9 @@ function normalizePunctuation(value: string): string {
|
|
|
713
544
|
}
|
|
714
545
|
|
|
715
546
|
async function withMutationLocks<T>(paths: string[], callback: () => Promise<T>): Promise<T> {
|
|
716
|
-
|
|
547
|
+
// Pi also canonicalizes queue keys. Deduplicate aliases before nesting queues,
|
|
548
|
+
// or two names for one file would attempt to acquire the same lock twice.
|
|
549
|
+
const uniquePaths = [...new Set(await Promise.all(paths.map((path) => canonicalPath(path))))].sort();
|
|
717
550
|
const acquire = (index: number): Promise<T> =>
|
|
718
551
|
index === uniquePaths.length ? callback() : withFileMutationQueue(uniquePaths[index], () => acquire(index + 1));
|
|
719
552
|
return acquire(0);
|
|
@@ -728,14 +561,6 @@ function isMissingPathError(error: unknown): boolean {
|
|
|
728
561
|
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
729
562
|
}
|
|
730
563
|
|
|
731
|
-
function isNoEntryError(error: unknown): boolean {
|
|
732
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function isAlreadyExistsError(error: unknown): boolean {
|
|
736
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
737
|
-
}
|
|
738
|
-
|
|
739
564
|
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
740
565
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
741
566
|
}
|
package/native/addon.c
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
// Native POSIX *at bindings for descriptor-relative filesystem walks.
|
|
2
|
-
// Used by apply_patch on platforms without /proc/self/fd (macOS) to keep the
|
|
3
|
-
// same TOCTOU-safe, no-follow directory walk that Linux gets via procfs.
|
|
4
|
-
#include <node_api.h>
|
|
5
|
-
#include <fcntl.h>
|
|
6
|
-
#include <sys/stat.h>
|
|
7
|
-
#include <unistd.h>
|
|
8
|
-
#include <stdlib.h>
|
|
9
|
-
#include <errno.h>
|
|
10
|
-
#include <string.h>
|
|
11
|
-
|
|
12
|
-
static napi_value throw_errno(napi_env env, int err) {
|
|
13
|
-
const char *code = NULL;
|
|
14
|
-
switch (err) {
|
|
15
|
-
case ENOENT: code = "ENOENT"; break;
|
|
16
|
-
case EEXIST: code = "EEXIST"; break;
|
|
17
|
-
case ELOOP: code = "ELOOP"; break;
|
|
18
|
-
case ENOTDIR: code = "ENOTDIR"; break;
|
|
19
|
-
case EACCES: code = "EACCES"; break;
|
|
20
|
-
case EISDIR: code = "EISDIR"; break;
|
|
21
|
-
case ENAMETOOLONG: code = "ENAMETOOLONG"; break;
|
|
22
|
-
case EINVAL: code = "EINVAL"; break;
|
|
23
|
-
}
|
|
24
|
-
napi_value msg, errv;
|
|
25
|
-
if (napi_create_string_utf8(env, strerror(err), NAPI_AUTO_LENGTH, &msg) != napi_ok ||
|
|
26
|
-
napi_create_error(env, NULL, msg, &errv) != napi_ok) {
|
|
27
|
-
napi_throw_error(env, NULL, strerror(err));
|
|
28
|
-
return NULL;
|
|
29
|
-
}
|
|
30
|
-
if (code) {
|
|
31
|
-
napi_value codev;
|
|
32
|
-
napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &codev);
|
|
33
|
-
napi_set_named_property(env, errv, "code", codev);
|
|
34
|
-
}
|
|
35
|
-
napi_value enov;
|
|
36
|
-
napi_create_int32(env, err, &enov);
|
|
37
|
-
napi_set_named_property(env, errv, "errno", enov);
|
|
38
|
-
napi_throw(env, errv);
|
|
39
|
-
return NULL;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
static char *get_string(napi_env env, napi_value v) {
|
|
43
|
-
size_t len = 0;
|
|
44
|
-
if (napi_get_value_string_utf8(env, v, NULL, 0, &len) != napi_ok) return NULL;
|
|
45
|
-
char *buf = (char *)malloc(len + 1);
|
|
46
|
-
if (!buf) return NULL;
|
|
47
|
-
size_t written = 0;
|
|
48
|
-
if (napi_get_value_string_utf8(env, v, buf, len + 1, &written) != napi_ok) { free(buf); return NULL; }
|
|
49
|
-
return buf;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
static napi_value OpenAt(napi_env env, napi_callback_info info) {
|
|
53
|
-
size_t argc = 4;
|
|
54
|
-
napi_value args[4];
|
|
55
|
-
if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 3) {
|
|
56
|
-
napi_throw_type_error(env, NULL, "openat(dirfd, path, flags[, mode]) requires (dirfd, path, flags)");
|
|
57
|
-
return NULL;
|
|
58
|
-
}
|
|
59
|
-
int dirfd = 0, flags = 0, mode = 0;
|
|
60
|
-
if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok ||
|
|
61
|
-
napi_get_value_int32(env, args[2], &flags) != napi_ok) {
|
|
62
|
-
napi_throw_type_error(env, NULL, "dirfd and flags must be integers");
|
|
63
|
-
return NULL;
|
|
64
|
-
}
|
|
65
|
-
char *path = get_string(env, args[1]);
|
|
66
|
-
if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
|
|
67
|
-
if (argc >= 4 && napi_get_value_int32(env, args[3], &mode) != napi_ok) {
|
|
68
|
-
free(path); napi_throw_type_error(env, NULL, "mode must be an integer"); return NULL;
|
|
69
|
-
}
|
|
70
|
-
int fd = openat(dirfd, path, flags, (mode_t)mode);
|
|
71
|
-
int saved_errno = errno;
|
|
72
|
-
free(path);
|
|
73
|
-
if (fd < 0) return throw_errno(env, saved_errno);
|
|
74
|
-
napi_value result;
|
|
75
|
-
napi_create_int32(env, fd, &result);
|
|
76
|
-
return result;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
static napi_value MkdirAt(napi_env env, napi_callback_info info) {
|
|
80
|
-
size_t argc = 3;
|
|
81
|
-
napi_value args[3];
|
|
82
|
-
if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
|
|
83
|
-
napi_throw_type_error(env, NULL, "mkdirat(dirfd, path[, mode]) requires (dirfd, path)");
|
|
84
|
-
return NULL;
|
|
85
|
-
}
|
|
86
|
-
int dirfd = 0, mode = 0777;
|
|
87
|
-
if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) { napi_throw_type_error(env, NULL, "dirfd must be an integer"); return NULL; }
|
|
88
|
-
char *path = get_string(env, args[1]);
|
|
89
|
-
if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
|
|
90
|
-
if (argc >= 3 && napi_get_value_int32(env, args[2], &mode) != napi_ok) { free(path); napi_throw_type_error(env, NULL, "mode must be an integer"); return NULL; }
|
|
91
|
-
int rc = mkdirat(dirfd, path, (mode_t)mode);
|
|
92
|
-
int saved_errno = errno;
|
|
93
|
-
free(path);
|
|
94
|
-
if (rc < 0) return throw_errno(env, saved_errno);
|
|
95
|
-
napi_value undef;
|
|
96
|
-
napi_get_undefined(env, &undef);
|
|
97
|
-
return undef;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
static napi_value UnlinkAt(napi_env env, napi_callback_info info) {
|
|
101
|
-
size_t argc = 2;
|
|
102
|
-
napi_value args[2];
|
|
103
|
-
if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
|
|
104
|
-
napi_throw_type_error(env, NULL, "unlinkat(dirfd, path) requires (dirfd, path)");
|
|
105
|
-
return NULL;
|
|
106
|
-
}
|
|
107
|
-
int dirfd = 0;
|
|
108
|
-
if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) { napi_throw_type_error(env, NULL, "dirfd must be an integer"); return NULL; }
|
|
109
|
-
char *path = get_string(env, args[1]);
|
|
110
|
-
if (!path) { napi_throw_type_error(env, NULL, "path must be a string"); return NULL; }
|
|
111
|
-
int rc = unlinkat(dirfd, path, 0);
|
|
112
|
-
int saved_errno = errno;
|
|
113
|
-
free(path);
|
|
114
|
-
if (rc < 0) return throw_errno(env, saved_errno);
|
|
115
|
-
napi_value undef;
|
|
116
|
-
napi_get_undefined(env, &undef);
|
|
117
|
-
return undef;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
static napi_value LstatAt(napi_env env, napi_callback_info info) {
|
|
121
|
-
size_t argc = 2;
|
|
122
|
-
napi_value args[2];
|
|
123
|
-
if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || argc < 2) {
|
|
124
|
-
napi_throw_type_error(env, NULL, "lstatAt(dirfd, path) requires (dirfd, path)");
|
|
125
|
-
return NULL;
|
|
126
|
-
}
|
|
127
|
-
int dirfd = 0;
|
|
128
|
-
if (napi_get_value_int32(env, args[0], &dirfd) != napi_ok) {
|
|
129
|
-
napi_throw_type_error(env, NULL, "dirfd must be an integer");
|
|
130
|
-
return NULL;
|
|
131
|
-
}
|
|
132
|
-
char *path = get_string(env, args[1]);
|
|
133
|
-
if (!path) {
|
|
134
|
-
napi_throw_type_error(env, NULL, "path must be a string");
|
|
135
|
-
return NULL;
|
|
136
|
-
}
|
|
137
|
-
struct stat st;
|
|
138
|
-
int rc = fstatat(dirfd, path, &st, AT_SYMLINK_NOFOLLOW);
|
|
139
|
-
int saved_errno = errno;
|
|
140
|
-
free(path);
|
|
141
|
-
if (rc < 0) return throw_errno(env, saved_errno);
|
|
142
|
-
napi_value obj, v;
|
|
143
|
-
napi_create_object(env, &obj);
|
|
144
|
-
napi_get_boolean(env, S_ISREG(st.st_mode), &v); napi_set_named_property(env, obj, "isFile", v);
|
|
145
|
-
napi_get_boolean(env, S_ISDIR(st.st_mode), &v); napi_set_named_property(env, obj, "isDirectory", v);
|
|
146
|
-
napi_get_boolean(env, S_ISLNK(st.st_mode), &v); napi_set_named_property(env, obj, "isSymbolicLink", v);
|
|
147
|
-
return obj;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
static napi_value Init(napi_env env, napi_value exports) {
|
|
151
|
-
const napi_property_descriptor props[] = {
|
|
152
|
-
{ "openat", NULL, OpenAt, NULL, NULL, NULL, napi_default, NULL },
|
|
153
|
-
{ "mkdirat", NULL, MkdirAt, NULL, NULL, NULL, napi_default, NULL },
|
|
154
|
-
{ "unlinkat", NULL, UnlinkAt, NULL, NULL, NULL, napi_default, NULL },
|
|
155
|
-
{ "lstatAt", NULL, LstatAt, NULL, NULL, NULL, napi_default, NULL },
|
|
156
|
-
};
|
|
157
|
-
napi_define_properties(env, exports, 4, props);
|
|
158
|
-
return exports;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
|
Binary file
|
|
Binary file
|
package/src/native.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* POSIX *at bindings for descriptor-relative filesystem walks. macOS has no
|
|
7
|
-
* procfs, so the TOCTOU-safe walk used by apply_patch (open each directory
|
|
8
|
-
* component relative to a trusted parent fd with O_NOFOLLOW) needs openat /
|
|
9
|
-
* mkdirat / unlinkat. Node does not expose these, so a tiny N-API addon
|
|
10
|
-
* provides them on darwin. Linux keeps its /proc/self/fd path and never loads
|
|
11
|
-
* this binding.
|
|
12
|
-
*/
|
|
13
|
-
export interface OpenAtBindings {
|
|
14
|
-
openat(dirfd: number, path: string, flags: number, mode: number): number;
|
|
15
|
-
mkdirat(dirfd: number, path: string, mode: number): void;
|
|
16
|
-
unlinkat(dirfd: number, path: string): void;
|
|
17
|
-
lstatAt(dirfd: number, path: string): { isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
let cached: OpenAtBindings | null | undefined;
|
|
21
|
-
|
|
22
|
-
/** Returns the native *at bindings on darwin, or null on any other platform / load failure. */
|
|
23
|
-
export function getOpenAtBindings(): OpenAtBindings | null {
|
|
24
|
-
if (cached !== undefined) return cached;
|
|
25
|
-
if (process.platform !== "darwin") {
|
|
26
|
-
cached = null;
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
const require = createRequire(import.meta.url);
|
|
31
|
-
const resolveBinding = require("node-gyp-build") as (dir: string) => unknown;
|
|
32
|
-
// native.ts lives in src/; the binding (build/ or prebuilds/) ships at the package root.
|
|
33
|
-
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
-
const addon = resolveBinding(packageRoot) as OpenAtBindings;
|
|
35
|
-
cached = typeof addon?.openat === "function" ? addon : null;
|
|
36
|
-
} catch {
|
|
37
|
-
cached = null;
|
|
38
|
-
}
|
|
39
|
-
return cached;
|
|
40
|
-
}
|