@openclaw/fs-safe 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/archive-utils.d.ts +3 -0
  3. package/dist/archive-utils.d.ts.map +1 -0
  4. package/dist/archive-utils.js +26 -0
  5. package/dist/boundary-file-read.d.ts +44 -0
  6. package/dist/boundary-file-read.d.ts.map +1 -0
  7. package/dist/boundary-file-read.js +129 -0
  8. package/dist/boundary-path.d.ts +39 -0
  9. package/dist/boundary-path.d.ts.map +1 -0
  10. package/dist/boundary-path.js +598 -0
  11. package/dist/fs-pinned-path-helper.d.ts +7 -0
  12. package/dist/fs-pinned-path-helper.d.ts.map +1 -0
  13. package/dist/fs-pinned-path-helper.js +182 -0
  14. package/dist/fs-pinned-write-helper.d.ts +21 -0
  15. package/dist/fs-pinned-write-helper.d.ts.map +1 -0
  16. package/dist/fs-pinned-write-helper.js +263 -0
  17. package/dist/hardlink-guards.d.ts +7 -0
  18. package/dist/hardlink-guards.d.ts.map +1 -0
  19. package/dist/hardlink-guards.js +30 -0
  20. package/dist/install-safe-path.d.ts +20 -0
  21. package/dist/install-safe-path.d.ts.map +1 -0
  22. package/dist/install-safe-path.js +94 -0
  23. package/dist/json-file.d.ts +3 -0
  24. package/dist/json-file.d.ts.map +1 -0
  25. package/dist/json-file.js +123 -0
  26. package/dist/json-files.d.ts +20 -0
  27. package/dist/json-files.d.ts.map +1 -0
  28. package/dist/json-files.js +153 -0
  29. package/dist/path-alias-guards.d.ts +19 -0
  30. package/dist/path-alias-guards.d.ts.map +1 -0
  31. package/dist/path-alias-guards.js +21 -0
  32. package/dist/path-guards.d.ts +7 -0
  33. package/dist/path-guards.d.ts.map +1 -0
  34. package/dist/path-guards.js +49 -0
  35. package/dist/path-safety.d.ts +12 -0
  36. package/dist/path-safety.d.ts.map +1 -0
  37. package/dist/path-safety.js +50 -0
  38. package/dist/private-file-store.d.ts +7 -5
  39. package/dist/private-file-store.d.ts.map +1 -1
  40. package/dist/private-file-store.js +34 -21
  41. package/dist/safe-open-sync.d.ts +24 -0
  42. package/dist/safe-open-sync.d.ts.map +1 -0
  43. package/dist/safe-open-sync.js +71 -0
  44. package/dist/safe-root.d.ts +123 -0
  45. package/dist/safe-root.d.ts.map +1 -0
  46. package/dist/safe-root.js +1060 -0
  47. package/dist/secure-temp-workspace.d.ts +25 -0
  48. package/dist/secure-temp-workspace.d.ts.map +1 -0
  49. package/dist/secure-temp-workspace.js +136 -0
  50. package/dist/sibling-temp-file.d.ts +16 -0
  51. package/dist/sibling-temp-file.d.ts.map +1 -0
  52. package/dist/sibling-temp-file.js +73 -0
  53. package/dist/sibling-temp-write.d.ts +8 -0
  54. package/dist/sibling-temp-write.d.ts.map +1 -0
  55. package/dist/sibling-temp-write.js +40 -0
  56. package/dist/sidecar-lock.d.ts +8 -1
  57. package/dist/sidecar-lock.d.ts.map +1 -1
  58. package/dist/sidecar-lock.js +40 -7
  59. package/docs/config.md +2 -2
  60. package/docs/sidecar-lock.md +33 -5
  61. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ import { spawn } from "node:child_process";
2
+ import fsSync from "node:fs";
3
+ import { FsSafeError } from "./errors.js";
4
+ const LOCAL_PINNED_PATH_PYTHON = [
5
+ "import errno",
6
+ "import json",
7
+ "import os",
8
+ "import stat",
9
+ "import sys",
10
+ "",
11
+ "operation = sys.argv[1]",
12
+ "root_path = sys.argv[2]",
13
+ "relative_path = sys.argv[3]",
14
+ "",
15
+ "DIR_FLAGS = os.O_RDONLY",
16
+ "if hasattr(os, 'O_DIRECTORY'):",
17
+ " DIR_FLAGS |= os.O_DIRECTORY",
18
+ "if hasattr(os, 'O_NOFOLLOW'):",
19
+ " DIR_FLAGS |= os.O_NOFOLLOW",
20
+ "",
21
+ "def open_dir(path_value, dir_fd=None):",
22
+ " return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
23
+ "",
24
+ "def split_segments(relative_path):",
25
+ " return [part for part in relative_path.split('/') if part and part != '.']",
26
+ "",
27
+ "def validate_segment(segment):",
28
+ " if segment == '..':",
29
+ " raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
30
+ "",
31
+ "def walk_existing_path(root_fd, segments):",
32
+ " current_fd = os.dup(root_fd)",
33
+ " try:",
34
+ " for segment in segments:",
35
+ " validate_segment(segment)",
36
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
37
+ " os.close(current_fd)",
38
+ " current_fd = next_fd",
39
+ " return current_fd",
40
+ " except Exception:",
41
+ " os.close(current_fd)",
42
+ " raise",
43
+ "",
44
+ "def mkdirp_within_root(root_fd, segments):",
45
+ " current_fd = os.dup(root_fd)",
46
+ " try:",
47
+ " for segment in segments:",
48
+ " validate_segment(segment)",
49
+ " try:",
50
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
51
+ " except FileNotFoundError:",
52
+ " os.mkdir(segment, 0o777, dir_fd=current_fd)",
53
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
54
+ " os.close(current_fd)",
55
+ " current_fd = next_fd",
56
+ " finally:",
57
+ " os.close(current_fd)",
58
+ "",
59
+ "def remove_within_root(root_fd, segments):",
60
+ " if not segments:",
61
+ " raise OSError(errno.EPERM, 'refusing to remove root path')",
62
+ " parent_segments = segments[:-1]",
63
+ " basename = segments[-1]",
64
+ " validate_segment(basename)",
65
+ " parent_fd = walk_existing_path(root_fd, parent_segments)",
66
+ " try:",
67
+ " target_stat = os.lstat(basename, dir_fd=parent_fd)",
68
+ " if stat.S_ISDIR(target_stat.st_mode) and not stat.S_ISLNK(target_stat.st_mode):",
69
+ " os.rmdir(basename, dir_fd=parent_fd)",
70
+ " else:",
71
+ " os.unlink(basename, dir_fd=parent_fd)",
72
+ " finally:",
73
+ " os.close(parent_fd)",
74
+ "",
75
+ "def emit_error(exc):",
76
+ " payload = {",
77
+ " 'name': exc.__class__.__name__,",
78
+ " 'errno': getattr(exc, 'errno', None),",
79
+ " 'message': str(exc),",
80
+ " }",
81
+ " print(json.dumps(payload), file=sys.stderr)",
82
+ "",
83
+ "root_fd = None",
84
+ "try:",
85
+ " root_fd = open_dir(root_path)",
86
+ " segments = split_segments(relative_path)",
87
+ " if operation == 'mkdirp':",
88
+ " mkdirp_within_root(root_fd, segments)",
89
+ " elif operation == 'remove':",
90
+ " remove_within_root(root_fd, segments)",
91
+ " else:",
92
+ " raise RuntimeError(f'unknown pinned path operation: {operation}')",
93
+ "except Exception as exc:",
94
+ " emit_error(exc)",
95
+ " sys.exit(1)",
96
+ "finally:",
97
+ " if root_fd is not None:",
98
+ " os.close(root_fd)",
99
+ ].join("\n");
100
+ const PINNED_PATH_PYTHON_CANDIDATES = [
101
+ process.env.OPENCLAW_PINNED_PYTHON,
102
+ // Keep the write-specific alias for backwards compatibility.
103
+ process.env.OPENCLAW_PINNED_WRITE_PYTHON,
104
+ "/usr/bin/python3",
105
+ "/opt/homebrew/bin/python3",
106
+ "/usr/local/bin/python3",
107
+ ].filter((value) => Boolean(value));
108
+ let cachedPinnedPathPython = "";
109
+ function canExecute(binPath) {
110
+ try {
111
+ fsSync.accessSync(binPath, fsSync.constants.X_OK);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function resolvePinnedPathPython() {
119
+ if (cachedPinnedPathPython) {
120
+ return cachedPinnedPathPython;
121
+ }
122
+ for (const candidate of PINNED_PATH_PYTHON_CANDIDATES) {
123
+ if (canExecute(candidate)) {
124
+ cachedPinnedPathPython = candidate;
125
+ return cachedPinnedPathPython;
126
+ }
127
+ }
128
+ cachedPinnedPathPython = "python3";
129
+ return cachedPinnedPathPython;
130
+ }
131
+ function buildPinnedPathError(stderr, code, signal) {
132
+ const trimmed = stderr.trim();
133
+ if (trimmed.startsWith("{")) {
134
+ try {
135
+ const payload = JSON.parse(trimmed);
136
+ if (payload.errno === 2) {
137
+ return new FsSafeError("not-found", "file not found");
138
+ }
139
+ if (payload.errno === 20 || payload.errno === 40) {
140
+ return new FsSafeError("path-alias", "path is not under root");
141
+ }
142
+ if (payload.errno === 39) {
143
+ return new FsSafeError("not-empty", "directory is not empty");
144
+ }
145
+ if (payload.errno === 1 || payload.errno === 13 || payload.errno === 21) {
146
+ return new FsSafeError("not-removable", "path is not removable under root");
147
+ }
148
+ return new FsSafeError("helper-failed", payload.message || "pinned path helper failed");
149
+ }
150
+ catch {
151
+ // Fall through to the generic helper failure below.
152
+ }
153
+ }
154
+ return new FsSafeError("helper-failed", trimmed || `Pinned path helper failed with code ${code ?? "null"} (${signal ?? "?"})`);
155
+ }
156
+ export function isPinnedPathHelperSpawnError(error) {
157
+ if (!(error instanceof Error)) {
158
+ return false;
159
+ }
160
+ const maybeErrno = error;
161
+ if (typeof maybeErrno.syscall !== "string" || !maybeErrno.syscall.startsWith("spawn")) {
162
+ return false;
163
+ }
164
+ return ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? "");
165
+ }
166
+ export async function runPinnedPathHelper(params) {
167
+ const child = spawn(resolvePinnedPathPython(), ["-c", LOCAL_PINNED_PATH_PYTHON, params.operation, params.rootPath, params.relativePath], {
168
+ stdio: ["ignore", "ignore", "pipe"],
169
+ });
170
+ let stderr = "";
171
+ child.stderr.setEncoding?.("utf8");
172
+ child.stderr.on("data", (chunk) => {
173
+ stderr += chunk;
174
+ });
175
+ const [code, signal] = await new Promise((resolve, reject) => {
176
+ child.once("error", reject);
177
+ child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
178
+ });
179
+ if (code !== 0) {
180
+ throw buildPinnedPathError(stderr, code, signal);
181
+ }
182
+ }
@@ -0,0 +1,21 @@
1
+ import type { Readable } from "node:stream";
2
+ import type { FileIdentityStat } from "./file-identity.js";
3
+ type PinnedWriteInput = {
4
+ kind: "buffer";
5
+ data: string | Buffer;
6
+ encoding?: BufferEncoding;
7
+ } | {
8
+ kind: "stream";
9
+ stream: Readable;
10
+ };
11
+ export declare function runPinnedWriteHelper(params: {
12
+ rootPath: string;
13
+ relativeParentPath: string;
14
+ basename: string;
15
+ mkdir: boolean;
16
+ mode: number;
17
+ overwrite?: boolean;
18
+ input: PinnedWriteInput;
19
+ }): Promise<FileIdentityStat>;
20
+ export {};
21
+ //# sourceMappingURL=fs-pinned-write-helper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-pinned-write-helper.d.ts","sourceRoot":"","sources":["../src/fs-pinned-write-helper.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAoJzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgE5B"}
@@ -0,0 +1,263 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import fsSync from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { pipeline } from "node:stream/promises";
7
+ const LOCAL_PINNED_WRITE_PYTHON = [
8
+ "import errno",
9
+ "import os",
10
+ "import secrets",
11
+ "import stat",
12
+ "import sys",
13
+ "",
14
+ "root_path = sys.argv[1]",
15
+ "relative_parent = sys.argv[2]",
16
+ "basename = sys.argv[3]",
17
+ 'mkdir_enabled = sys.argv[4] == "1"',
18
+ "file_mode = int(sys.argv[5], 8)",
19
+ 'overwrite_enabled = sys.argv[6] == "1"',
20
+ "",
21
+ "DIR_FLAGS = os.O_RDONLY",
22
+ "if hasattr(os, 'O_DIRECTORY'):",
23
+ " DIR_FLAGS |= os.O_DIRECTORY",
24
+ "if hasattr(os, 'O_NOFOLLOW'):",
25
+ " DIR_FLAGS |= os.O_NOFOLLOW",
26
+ "",
27
+ "WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL",
28
+ "if hasattr(os, 'O_NOFOLLOW'):",
29
+ " WRITE_FLAGS |= os.O_NOFOLLOW",
30
+ "",
31
+ "def open_dir(path_value, dir_fd=None):",
32
+ " return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
33
+ "",
34
+ "def walk_parent(root_fd, rel_parent, mkdir_enabled):",
35
+ " current_fd = os.dup(root_fd)",
36
+ " try:",
37
+ " for segment in [part for part in rel_parent.split('/') if part and part != '.']:",
38
+ " if segment == '..':",
39
+ " raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
40
+ " try:",
41
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
42
+ " except FileNotFoundError:",
43
+ " if not mkdir_enabled:",
44
+ " raise",
45
+ " os.mkdir(segment, 0o777, dir_fd=current_fd)",
46
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
47
+ " os.close(current_fd)",
48
+ " current_fd = next_fd",
49
+ " return current_fd",
50
+ " except Exception:",
51
+ " os.close(current_fd)",
52
+ " raise",
53
+ "",
54
+ "def create_temp_file(parent_fd, basename, mode):",
55
+ " prefix = '.' + basename + '.'",
56
+ " for _ in range(128):",
57
+ " candidate = prefix + secrets.token_hex(6) + '.tmp'",
58
+ " try:",
59
+ " fd = os.open(candidate, WRITE_FLAGS, mode, dir_fd=parent_fd)",
60
+ " return candidate, fd",
61
+ " except FileExistsError:",
62
+ " continue",
63
+ " raise RuntimeError('failed to allocate pinned temp file')",
64
+ "",
65
+ "root_fd = open_dir(root_path)",
66
+ "parent_fd = None",
67
+ "temp_fd = None",
68
+ "temp_name = None",
69
+ "try:",
70
+ " parent_fd = walk_parent(root_fd, relative_parent, mkdir_enabled)",
71
+ " temp_name, temp_fd = create_temp_file(parent_fd, basename, file_mode)",
72
+ " while True:",
73
+ " chunk = sys.stdin.buffer.read(65536)",
74
+ " if not chunk:",
75
+ " break",
76
+ " os.write(temp_fd, chunk)",
77
+ " os.fsync(temp_fd)",
78
+ " os.close(temp_fd)",
79
+ " temp_fd = None",
80
+ " if overwrite_enabled:",
81
+ " os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)",
82
+ " temp_name = None",
83
+ " else:",
84
+ " os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)",
85
+ " os.unlink(temp_name, dir_fd=parent_fd)",
86
+ " temp_name = None",
87
+ " os.fsync(parent_fd)",
88
+ " result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)",
89
+ " print(f'{result_stat.st_dev}|{result_stat.st_ino}')",
90
+ "finally:",
91
+ " if temp_fd is not None:",
92
+ " os.close(temp_fd)",
93
+ " if temp_name is not None and parent_fd is not None:",
94
+ " try:",
95
+ " os.unlink(temp_name, dir_fd=parent_fd)",
96
+ " except FileNotFoundError:",
97
+ " pass",
98
+ " if parent_fd is not None:",
99
+ " os.close(parent_fd)",
100
+ " os.close(root_fd)",
101
+ ].join("\n");
102
+ const PINNED_WRITE_PYTHON_CANDIDATES = [
103
+ process.env.OPENCLAW_PINNED_WRITE_PYTHON,
104
+ "/usr/bin/python3",
105
+ "/opt/homebrew/bin/python3",
106
+ "/usr/local/bin/python3",
107
+ ].filter((value) => Boolean(value));
108
+ let cachedPinnedWritePython = "";
109
+ function canExecute(binPath) {
110
+ try {
111
+ fsSync.accessSync(binPath, fsSync.constants.X_OK);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function resolvePinnedWritePython() {
119
+ if (cachedPinnedWritePython) {
120
+ return cachedPinnedWritePython;
121
+ }
122
+ for (const candidate of PINNED_WRITE_PYTHON_CANDIDATES) {
123
+ if (canExecute(candidate)) {
124
+ cachedPinnedWritePython = candidate;
125
+ return cachedPinnedWritePython;
126
+ }
127
+ }
128
+ cachedPinnedWritePython = "python3";
129
+ return cachedPinnedWritePython;
130
+ }
131
+ function parsePinnedIdentity(stdout) {
132
+ const line = stdout
133
+ .trim()
134
+ .split(/\r?\n/)
135
+ .map((value) => value.trim())
136
+ .findLast(Boolean);
137
+ if (!line) {
138
+ throw new Error("Pinned write helper returned no identity");
139
+ }
140
+ const [devRaw, inoRaw] = line.split("|");
141
+ const dev = Number.parseInt(devRaw ?? "", 10);
142
+ const ino = Number.parseInt(inoRaw ?? "", 10);
143
+ if (!Number.isFinite(dev) || !Number.isFinite(ino)) {
144
+ throw new Error(`Pinned write helper returned invalid identity: ${line}`);
145
+ }
146
+ return { dev, ino };
147
+ }
148
+ export async function runPinnedWriteHelper(params) {
149
+ const child = spawn(resolvePinnedWritePython(), [
150
+ "-c",
151
+ LOCAL_PINNED_WRITE_PYTHON,
152
+ params.rootPath,
153
+ params.relativeParentPath,
154
+ params.basename,
155
+ params.mkdir ? "1" : "0",
156
+ (params.mode || 0o600).toString(8),
157
+ params.overwrite === false ? "0" : "1",
158
+ ], {
159
+ stdio: ["pipe", "pipe", "pipe"],
160
+ });
161
+ let stdout = "";
162
+ let stderr = "";
163
+ child.stdout.setEncoding?.("utf8");
164
+ child.stderr.setEncoding?.("utf8");
165
+ child.stdout.on("data", (chunk) => {
166
+ stdout += chunk;
167
+ });
168
+ child.stderr.on("data", (chunk) => {
169
+ stderr += chunk;
170
+ });
171
+ const exitPromise = once(child, "close");
172
+ try {
173
+ if (!child.stdin) {
174
+ const identity = await runPinnedWriteFallback(params);
175
+ await exitPromise.catch(() => { });
176
+ return identity;
177
+ }
178
+ if (params.input.kind === "buffer") {
179
+ const input = params.input;
180
+ await new Promise((resolve, reject) => {
181
+ child.stdin.once("error", reject);
182
+ if (typeof input.data === "string") {
183
+ child.stdin.end(input.data, input.encoding ?? "utf8", () => resolve());
184
+ return;
185
+ }
186
+ child.stdin.end(input.data, () => resolve());
187
+ });
188
+ }
189
+ else {
190
+ await pipeline(params.input.stream, child.stdin);
191
+ }
192
+ const [code, signal] = await exitPromise;
193
+ if (code !== 0) {
194
+ throw new Error(stderr.trim() ||
195
+ `Pinned write helper failed with code ${code ?? "null"} (${signal ?? "?"})`);
196
+ }
197
+ return parsePinnedIdentity(stdout);
198
+ }
199
+ catch (error) {
200
+ child.kill("SIGKILL");
201
+ await exitPromise.catch(() => { });
202
+ throw error;
203
+ }
204
+ }
205
+ async function runPinnedWriteFallback(params) {
206
+ const parentPath = params.relativeParentPath
207
+ ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
208
+ : params.rootPath;
209
+ if (params.mkdir) {
210
+ await fs.mkdir(parentPath, { recursive: true });
211
+ }
212
+ const targetPath = path.join(parentPath, params.basename);
213
+ if (params.overwrite === false) {
214
+ const handle = await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode);
215
+ let created = true;
216
+ try {
217
+ if (params.input.kind === "buffer") {
218
+ if (typeof params.input.data === "string") {
219
+ await handle.writeFile(params.input.data, params.input.encoding ?? "utf8");
220
+ }
221
+ else {
222
+ await handle.writeFile(params.input.data);
223
+ }
224
+ }
225
+ else {
226
+ await pipeline(params.input.stream, handle.createWriteStream());
227
+ }
228
+ const stat = await handle.stat();
229
+ created = false;
230
+ return { dev: stat.dev, ino: stat.ino };
231
+ }
232
+ finally {
233
+ await handle.close().catch(() => undefined);
234
+ if (created) {
235
+ await fs.rm(targetPath, { force: true }).catch(() => undefined);
236
+ }
237
+ }
238
+ }
239
+ const tempPath = path.join(parentPath, `.${params.basename}.fallback.tmp`);
240
+ if (params.input.kind === "buffer") {
241
+ if (typeof params.input.data === "string") {
242
+ await fs.writeFile(tempPath, params.input.data, {
243
+ encoding: params.input.encoding ?? "utf8",
244
+ mode: params.mode,
245
+ });
246
+ }
247
+ else {
248
+ await fs.writeFile(tempPath, params.input.data, { mode: params.mode });
249
+ }
250
+ }
251
+ else {
252
+ const handle = await fs.open(tempPath, "w", params.mode);
253
+ try {
254
+ await pipeline(params.input.stream, handle.createWriteStream());
255
+ }
256
+ finally {
257
+ await handle.close().catch(() => { });
258
+ }
259
+ }
260
+ await fs.rename(tempPath, targetPath);
261
+ const stat = await fs.stat(targetPath);
262
+ return { dev: stat.dev, ino: stat.ino };
263
+ }
@@ -0,0 +1,7 @@
1
+ export declare function assertNoHardlinkedFinalPath(params: {
2
+ filePath: string;
3
+ root: string;
4
+ boundaryLabel: string;
5
+ allowFinalHardlinkForUnlink?: boolean;
6
+ }): Promise<void>;
7
+ //# sourceMappingURL=hardlink-guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hardlink-guards.d.ts","sourceRoot":"","sources":["../src/hardlink-guards.ts"],"names":[],"mappings":"AAIA,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBhB"}
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import { isNotFoundPathError } from "./path-guards.js";
4
+ export async function assertNoHardlinkedFinalPath(params) {
5
+ if (params.allowFinalHardlinkForUnlink) {
6
+ return;
7
+ }
8
+ let stat;
9
+ try {
10
+ stat = await fs.stat(params.filePath);
11
+ }
12
+ catch (err) {
13
+ if (isNotFoundPathError(err)) {
14
+ return;
15
+ }
16
+ throw err;
17
+ }
18
+ if (!stat.isFile()) {
19
+ return;
20
+ }
21
+ if (stat.nlink > 1) {
22
+ throw new Error(`Hardlinked path is not allowed under ${params.boundaryLabel} (${shortPath(params.root)}): ${shortPath(params.filePath)}`);
23
+ }
24
+ }
25
+ function shortPath(value) {
26
+ if (value.startsWith(os.homedir())) {
27
+ return `~${value.slice(os.homedir().length)}`;
28
+ }
29
+ return value;
30
+ }
@@ -0,0 +1,20 @@
1
+ export declare function safeDirName(input: string): string;
2
+ export declare function safePathSegmentHashed(input: string): string;
3
+ export declare function resolveSafeInstallDir(params: {
4
+ baseDir: string;
5
+ id: string;
6
+ invalidNameMessage: string;
7
+ nameEncoder?: (id: string) => string;
8
+ }): {
9
+ ok: true;
10
+ path: string;
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ export declare function assertCanonicalPathWithinBase(params: {
16
+ baseDir: string;
17
+ candidatePath: string;
18
+ boundaryLabel: string;
19
+ }): Promise<void>;
20
+ //# sourceMappingURL=install-safe-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install-safe-path.d.ts","sourceRoot":"","sources":["../src/install-safe-path.ts"],"names":[],"mappings":"AAKA,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMjD;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAsB3D;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;CACtC,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAe5D;AAED,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDhB"}
@@ -0,0 +1,94 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isPathInside } from "./path-guards.js";
5
+ export function safeDirName(input) {
6
+ const trimmed = input.trim();
7
+ if (!trimmed) {
8
+ return trimmed;
9
+ }
10
+ return trimmed.replaceAll("/", "__").replaceAll("\\", "__");
11
+ }
12
+ export function safePathSegmentHashed(input) {
13
+ const trimmed = input.trim();
14
+ const base = trimmed
15
+ .replaceAll(/[\\/]/g, "-")
16
+ .replaceAll(/[^a-zA-Z0-9._-]/g, "-")
17
+ .replaceAll(/-+/g, "-")
18
+ .replaceAll(/^-+/g, "")
19
+ .replaceAll(/-+$/g, "");
20
+ const normalized = base.length > 0 ? base : "skill";
21
+ const safe = normalized === "." || normalized === ".." ? "skill" : normalized;
22
+ const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 10);
23
+ if (safe !== trimmed) {
24
+ const prefix = safe.length > 50 ? safe.slice(0, 50) : safe;
25
+ return `${prefix}-${hash}`;
26
+ }
27
+ if (safe.length > 60) {
28
+ return `${safe.slice(0, 50)}-${hash}`;
29
+ }
30
+ return safe;
31
+ }
32
+ export function resolveSafeInstallDir(params) {
33
+ const encodedName = (params.nameEncoder ?? safeDirName)(params.id);
34
+ const targetDir = path.join(params.baseDir, encodedName);
35
+ const resolvedBase = path.resolve(params.baseDir);
36
+ const resolvedTarget = path.resolve(targetDir);
37
+ const relative = path.relative(resolvedBase, resolvedTarget);
38
+ if (!relative ||
39
+ relative === ".." ||
40
+ relative.startsWith(`..${path.sep}`) ||
41
+ path.isAbsolute(relative)) {
42
+ return { ok: false, error: params.invalidNameMessage };
43
+ }
44
+ return { ok: true, path: targetDir };
45
+ }
46
+ export async function assertCanonicalPathWithinBase(params) {
47
+ const baseDir = path.resolve(params.baseDir);
48
+ const candidatePath = path.resolve(params.candidatePath);
49
+ if (!isPathInside(baseDir, candidatePath)) {
50
+ throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
51
+ }
52
+ const baseLstat = await fs.lstat(baseDir);
53
+ if (baseLstat.isSymbolicLink()) {
54
+ const baseStat = await fs.stat(baseDir);
55
+ if (!baseStat.isDirectory()) {
56
+ throw new Error(`Invalid ${params.boundaryLabel}: base directory must resolve to a directory`);
57
+ }
58
+ }
59
+ else if (!baseLstat.isDirectory()) {
60
+ throw new Error(`Invalid ${params.boundaryLabel}: base directory must be a directory`);
61
+ }
62
+ const baseRealPath = await fs.realpath(baseDir);
63
+ const validateDirectory = async (dirPath) => {
64
+ const resolvedDirPath = path.resolve(dirPath);
65
+ const dirLstat = await fs.lstat(dirPath);
66
+ if (dirLstat.isSymbolicLink()) {
67
+ if (resolvedDirPath !== baseDir) {
68
+ throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
69
+ }
70
+ const dirStat = await fs.stat(dirPath);
71
+ if (!dirStat.isDirectory()) {
72
+ throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
73
+ }
74
+ }
75
+ else if (!dirLstat.isDirectory()) {
76
+ throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
77
+ }
78
+ const dirRealPath = await fs.realpath(dirPath);
79
+ if (!isPathInside(baseRealPath, dirRealPath)) {
80
+ throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
81
+ }
82
+ };
83
+ try {
84
+ await validateDirectory(candidatePath);
85
+ return;
86
+ }
87
+ catch (err) {
88
+ const code = err.code;
89
+ if (code !== "ENOENT") {
90
+ throw err;
91
+ }
92
+ }
93
+ await validateDirectory(path.dirname(candidatePath));
94
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loadJsonFile<T = unknown>(pathname: string): T | undefined;
2
+ export declare function saveJsonFile(pathname: string, data: unknown): void;
3
+ //# sourceMappingURL=json-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-file.d.ts","sourceRoot":"","sources":["../src/json-file.ts"],"names":[],"mappings":"AAkGA,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAOzE;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAqB3D"}