@openclaw/fs-safe 0.2.5 → 0.2.7

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.
@@ -4,6 +4,7 @@ export declare const PRIVATE_SECRET_FILE_MODE = 384;
4
4
  export type SecretFileReadOptions = {
5
5
  maxBytes?: number;
6
6
  rejectSymlink?: boolean;
7
+ rejectHardlinks?: boolean;
7
8
  };
8
9
  export declare function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;
9
10
  export declare function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAqHF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoFD,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhB"}
1
+ {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAkIF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAcpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
@@ -1,18 +1,22 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import fs from "node:fs";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { createAsyncDirectoryGuard } from "./directory-guard.js";
4
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
6
5
  import { FsSafeError } from "./errors.js";
7
- import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
6
+ import { sameFileIdentity } from "./file-identity.js";
8
7
  import { resolveHomeRelativePath } from "./home-dir.js";
9
8
  import { openPinnedFileSync } from "./pinned-open.js";
9
+ import { runPinnedWriteHelper } from "./pinned-write.js";
10
10
  export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
11
11
  export const PRIVATE_SECRET_DIR_MODE = 0o700;
12
12
  export const PRIVATE_SECRET_FILE_MODE = 0o600;
13
13
  function normalizeSecretReadError(error) {
14
14
  return error instanceof Error ? error : new Error(String(error));
15
15
  }
16
+ function secretPathErrorCode(error) {
17
+ const code = error.code;
18
+ return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
19
+ }
16
20
  function resolveUserPath(input) {
17
21
  return resolveHomeRelativePath(input);
18
22
  }
@@ -31,7 +35,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
31
35
  const normalized = normalizeSecretReadError(error);
32
36
  return {
33
37
  ok: false,
34
- code: error.code === "ENOENT" ? "not-found" : "invalid-path",
38
+ code: secretPathErrorCode(error),
35
39
  error: normalized,
36
40
  message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
37
41
  };
@@ -45,7 +49,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
45
49
  const normalized = normalizeSecretReadError(error);
46
50
  return {
47
51
  ok: false,
48
- code: error.code === "ENOENT" ? "not-found" : "invalid-path",
52
+ code: secretPathErrorCode(error),
49
53
  error: normalized,
50
54
  message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
51
55
  };
@@ -66,6 +70,13 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
66
70
  message: `${label} file at ${resolvedPath} must be a regular file.`,
67
71
  };
68
72
  }
73
+ if (options.rejectHardlinks !== false && previewStat.nlink > 1) {
74
+ return {
75
+ ok: false,
76
+ code: "hardlink",
77
+ message: `${label} file at ${resolvedPath} must not be hardlinked.`,
78
+ };
79
+ }
69
80
  if (previewStat.size > maxBytes) {
70
81
  return {
71
82
  ok: false,
@@ -76,6 +87,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
76
87
  const opened = openPinnedFileSync({
77
88
  filePath: resolvedPath,
78
89
  rejectPathSymlink: options.rejectSymlink,
90
+ rejectHardlinks: options.rejectHardlinks !== false,
79
91
  maxBytes,
80
92
  });
81
93
  if (!opened.ok) {
@@ -126,17 +138,28 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
126
138
  return undefined;
127
139
  }
128
140
  const result = readSecretFileOutcomeSync(filePath, label, options);
129
- return result.ok ? result.secret : undefined;
141
+ if (result.ok) {
142
+ return result.secret;
143
+ }
144
+ if (result.code === "not-found") {
145
+ return undefined;
146
+ }
147
+ throw new FsSafeError(result.code, result.message, {
148
+ cause: result.error,
149
+ });
150
+ }
151
+ function isRelativeEscape(relativePath) {
152
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
130
153
  }
131
154
  function assertPathWithinRoot(rootDir, targetPath) {
132
155
  const relative = path.relative(rootDir, targetPath);
133
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
156
+ if (!relative || isRelativeEscape(relative)) {
134
157
  throw new Error(`Private secret path must stay under ${rootDir}.`);
135
158
  }
136
159
  }
137
160
  function assertRealPathWithinRoot(rootDir, targetPath) {
138
161
  const relative = path.relative(rootDir, targetPath);
139
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
162
+ if (isRelativeEscape(relative)) {
140
163
  throw new Error(`Private secret path must stay under ${rootDir}.`);
141
164
  }
142
165
  }
@@ -151,30 +174,62 @@ async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {
151
174
  throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
152
175
  }
153
176
  }
177
+ async function enforcePrivateFileIdentityAndMode(resolvedPath, expectedIdentity, expectedMode) {
178
+ const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
179
+ const handle = await fsp.open(resolvedPath, fs.constants.O_RDONLY | noFollowFlag);
180
+ try {
181
+ const openedStat = await handle.stat();
182
+ if (!openedStat.isFile() || !sameFileIdentity(openedStat, expectedIdentity)) {
183
+ throw new FsSafeError("path-mismatch", "private secret file changed during write");
184
+ }
185
+ const pathStat = await fsp.lstat(resolvedPath);
186
+ if (pathStat.isSymbolicLink() || !sameFileIdentity(pathStat, openedStat)) {
187
+ throw new FsSafeError("path-mismatch", "private secret path changed during write");
188
+ }
189
+ if (process.platform !== "win32") {
190
+ await handle.chmod(expectedMode);
191
+ const chmodStat = await handle.stat();
192
+ const actualMode = chmodStat.mode & 0o777;
193
+ if (actualMode !== expectedMode) {
194
+ throw new Error(`Private secret file ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
195
+ }
196
+ const refreshedPathStat = await fsp.lstat(resolvedPath);
197
+ if (refreshedPathStat.isSymbolicLink() || !sameFileIdentity(refreshedPathStat, chmodStat)) {
198
+ throw new FsSafeError("path-mismatch", "private secret path changed during mode check");
199
+ }
200
+ }
201
+ }
202
+ finally {
203
+ await handle.close().catch(() => undefined);
204
+ }
205
+ }
154
206
  async function ensurePrivateDirectory(rootDir, targetDir, mode) {
155
207
  const resolvedRoot = path.resolve(rootDir);
156
208
  const resolvedTarget = path.resolve(targetDir);
209
+ await fsp.mkdir(resolvedRoot, { recursive: true, mode });
210
+ const rootStat = await fsp.lstat(resolvedRoot);
211
+ if (rootStat.isSymbolicLink()) {
212
+ throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
213
+ }
214
+ if (!rootStat.isDirectory()) {
215
+ throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
216
+ }
217
+ const rootGuard = await createAsyncDirectoryGuard(resolvedRoot);
218
+ await enforcePrivatePathMode(rootGuard.realPath, mode, "directory");
219
+ await assertAsyncDirectoryGuard(rootGuard);
157
220
  if (resolvedTarget === resolvedRoot) {
158
- await fsp.mkdir(resolvedRoot, { recursive: true, mode });
159
- const rootStat = await fsp.lstat(resolvedRoot);
160
- if (rootStat.isSymbolicLink()) {
161
- throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
162
- }
163
- if (!rootStat.isDirectory()) {
164
- throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
165
- }
166
- await enforcePrivatePathMode(resolvedRoot, mode, "directory");
167
- return;
221
+ return { rootGuard, targetReal: rootGuard.realPath };
168
222
  }
169
223
  assertPathWithinRoot(resolvedRoot, resolvedTarget);
170
- await ensurePrivateDirectory(resolvedRoot, resolvedRoot, mode);
171
- const resolvedRootReal = await fsp.realpath(resolvedRoot);
224
+ const resolvedRootReal = rootGuard.realPath;
172
225
  let current = resolvedRoot;
173
226
  for (const segment of path
174
227
  .relative(resolvedRoot, resolvedTarget)
175
228
  .split(path.sep)
176
229
  .filter(Boolean)) {
177
230
  current = path.join(current, segment);
231
+ const parentGuard = await createAsyncDirectoryGuard(path.dirname(current));
232
+ await assertAsyncDirectoryGuard(rootGuard);
178
233
  try {
179
234
  const stat = await fsp.lstat(current);
180
235
  if (stat.isSymbolicLink()) {
@@ -188,12 +243,16 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
188
243
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
189
244
  throw error;
190
245
  }
246
+ await assertAsyncDirectoryGuard(parentGuard);
191
247
  await fsp.mkdir(current, { mode });
192
248
  }
193
249
  const currentReal = await fsp.realpath(current);
194
250
  assertRealPathWithinRoot(resolvedRootReal, currentReal);
195
251
  await enforcePrivatePathMode(currentReal, mode, "directory");
252
+ await assertAsyncDirectoryGuard(parentGuard);
253
+ await assertAsyncDirectoryGuard(rootGuard);
196
254
  }
255
+ return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
197
256
  }
198
257
  export async function writeSecretFileAtomic(params) {
199
258
  const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
@@ -202,13 +261,12 @@ export async function writeSecretFileAtomic(params) {
202
261
  const resolvedFile = path.resolve(params.filePath);
203
262
  assertPathWithinRoot(resolvedRoot, resolvedFile);
204
263
  const intendedParentDir = path.dirname(resolvedFile);
205
- await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
206
- const resolvedRootReal = await fsp.realpath(resolvedRoot);
207
- const parentDir = await fsp.realpath(intendedParentDir);
208
- assertRealPathWithinRoot(resolvedRootReal, parentDir);
209
- const parentGuard = await createAsyncDirectoryGuard(parentDir);
264
+ const { rootGuard, targetReal } = await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
265
+ await assertAsyncDirectoryGuard(rootGuard);
266
+ assertRealPathWithinRoot(rootGuard.realPath, targetReal);
267
+ const parentGuard = await createAsyncDirectoryGuard(targetReal);
210
268
  const fileName = path.basename(resolvedFile);
211
- const finalFilePath = path.join(parentDir, fileName);
269
+ const finalFilePath = path.join(targetReal, fileName);
212
270
  try {
213
271
  const stat = await fsp.lstat(finalFilePath);
214
272
  if (stat.isSymbolicLink()) {
@@ -223,31 +281,19 @@ export async function writeSecretFileAtomic(params) {
223
281
  throw error;
224
282
  }
225
283
  }
226
- const tempPath = path.join(parentDir, `.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}`);
227
- let createdTemp = false;
228
- try {
229
- const handle = await fsp.open(tempPath, "wx", mode);
230
- createdTemp = true;
231
- try {
232
- await handle.writeFile(params.content);
233
- }
234
- finally {
235
- await handle.close();
236
- }
237
- await enforcePrivatePathMode(tempPath, mode, "file");
238
- const refreshedParentReal = await fsp.realpath(intendedParentDir);
239
- if (refreshedParentReal !== parentDir) {
240
- throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
241
- }
242
- await withAsyncDirectoryGuards([parentGuard], async () => {
243
- await fsp.rename(tempPath, finalFilePath);
244
- });
245
- createdTemp = false;
246
- await enforcePrivatePathMode(finalFilePath, mode, "file");
247
- }
248
- finally {
249
- if (createdTemp) {
250
- await fsp.unlink(tempPath).catch(() => undefined);
251
- }
252
- }
284
+ await assertAsyncDirectoryGuard(rootGuard);
285
+ await assertAsyncDirectoryGuard(parentGuard);
286
+ const identity = await runPinnedWriteHelper({
287
+ rootPath: parentGuard.realPath,
288
+ relativeParentPath: "",
289
+ basename: fileName,
290
+ mkdir: false,
291
+ mode,
292
+ overwrite: true,
293
+ input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
294
+ rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
295
+ });
296
+ await assertAsyncDirectoryGuard(rootGuard);
297
+ await assertAsyncDirectoryGuard(parentGuard);
298
+ await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
253
299
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,aAAa,GAAG,qBAAqB,CAAC;AAE7E,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,yBAAyB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAChC,aAAa,CAAC,EAAE,wBAAwB,CAAC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,iBAAiB,EAAE,OAAO,CAAC;KAC5B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,qBAAqB,CAAC,EAAE,CACtB,QAAQ,EAAE,wBAAwB,KAC/B,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AA2OF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAW3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAiIL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -34,8 +34,11 @@ async function readLockSnapshot(lockPath) {
34
34
  return { raw, payload: null, stat };
35
35
  }
36
36
  }
37
- catch {
38
- return null;
37
+ catch (err) {
38
+ if (err.code === "ENOENT") {
39
+ return null;
40
+ }
41
+ throw err;
39
42
  }
40
43
  }
41
44
  function snapshotMatches(current, observed) {
@@ -86,7 +89,10 @@ async function removeStaleLockIfAllowed(params) {
86
89
  try {
87
90
  await fs.rm(params.lockPath, { force: true });
88
91
  }
89
- catch {
92
+ catch (err) {
93
+ if (err.code === "ENOENT") {
94
+ return "changed";
95
+ }
90
96
  return "not-approved";
91
97
  }
92
98
  return "removed";
@@ -1,12 +1,12 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { isNotFoundPathError } from "./path.js";
4
+ import { isNotFoundPathError, isPathRelativeEscape } from "./path.js";
5
5
  function resolvePathWalk(params) {
6
6
  const root = path.resolve(params.rootDir);
7
7
  const target = path.resolve(params.targetPath);
8
8
  const relative = path.relative(root, target);
9
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
9
+ if (isPathRelativeEscape(relative)) {
10
10
  if (params.allowOutsideRoot) {
11
11
  return null;
12
12
  }
@@ -52,7 +52,7 @@ async function cleanupTempDir(dir, onCleanupError) {
52
52
  }
53
53
  }
54
54
  function resolveTempRoot(rootDir) {
55
- return rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" });
55
+ return path.resolve(rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" }));
56
56
  }
57
57
  export async function tempFile(params) {
58
58
  const rootDir = resolveTempRoot(params.rootDir);
@@ -36,7 +36,7 @@ The 16 KiB cap is intentionally aggressive — credentials should be small. If y
36
36
 
37
37
  ### `tryReadSecretFileSync(filePath, label, options?)`
38
38
 
39
- The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing, empty, unreadable, too large, or rejected by the validation checks.
39
+ The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing or blank. Validation failures, unreadable files, oversized files, symlinks, and hardlinks throw `FsSafeError` so callers fail closed on suspicious credential state.
40
40
 
41
41
  ```ts
42
42
  import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
@@ -63,10 +63,11 @@ const token = readSecretFileSync("/var/lib/app/auth.token");
63
63
  type SecretFileReadOptions = {
64
64
  maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
65
65
  rejectSymlink?: boolean;
66
+ rejectHardlinks?: boolean; // default true
66
67
  };
67
68
  ```
68
69
 
69
- The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read.
70
+ The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
70
71
 
71
72
  ## Writing
72
73
 
package/docs/timing.md CHANGED
@@ -102,7 +102,7 @@ await extractArchive({
102
102
  });
103
103
  ```
104
104
 
105
- `extractArchive` already takes `timeoutMs` and uses `withTimeout` internally — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
105
+ `extractArchive` already takes `timeoutMs` and carries its own abort signal/deadline checks — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
106
106
 
107
107
  ### Disable in tests
108
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {