@openclaw/fs-safe 0.5.6 → 0.6.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.
Files changed (145) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +16 -10
  3. package/dist/advanced.d.ts +2 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts +2 -0
  7. package/dist/archive-crc32.d.ts.map +1 -0
  8. package/dist/archive-crc32.js +14 -0
  9. package/dist/archive-kind.js +2 -2
  10. package/dist/archive-native.d.ts.map +1 -1
  11. package/dist/archive-native.js +7 -0
  12. package/dist/archive-read.d.ts.map +1 -1
  13. package/dist/archive-read.js +24 -5
  14. package/dist/archive-tar-meta.d.ts.map +1 -1
  15. package/dist/archive-tar-meta.js +53 -6
  16. package/dist/archive-tar-pax.d.ts +8 -0
  17. package/dist/archive-tar-pax.d.ts.map +1 -0
  18. package/dist/archive-tar-pax.js +100 -0
  19. package/dist/archive-tar-runtime.d.ts +1 -0
  20. package/dist/archive-tar-runtime.d.ts.map +1 -1
  21. package/dist/archive-zip-admission.d.ts +7 -0
  22. package/dist/archive-zip-admission.d.ts.map +1 -0
  23. package/dist/archive-zip-admission.js +60 -0
  24. package/dist/archive-zip-count.d.ts +2 -0
  25. package/dist/archive-zip-count.d.ts.map +1 -0
  26. package/dist/archive-zip-count.js +140 -0
  27. package/dist/archive-zip-directory.d.ts +8 -0
  28. package/dist/archive-zip-directory.d.ts.map +1 -0
  29. package/dist/archive-zip-directory.js +224 -0
  30. package/dist/archive-zip-integrity.d.ts.map +1 -1
  31. package/dist/archive-zip-integrity.js +1 -14
  32. package/dist/archive-zip-names.d.ts +12 -0
  33. package/dist/archive-zip-names.d.ts.map +1 -0
  34. package/dist/archive-zip-names.js +99 -0
  35. package/dist/archive-zip-preflight.d.ts +1 -1
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +8 -145
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +32 -23
  40. package/dist/file-hash.d.ts.map +1 -1
  41. package/dist/file-hash.js +25 -19
  42. package/dist/file-lock-sync.d.ts.map +1 -1
  43. package/dist/file-lock-sync.js +19 -7
  44. package/dist/native-binding.d.ts +3 -0
  45. package/dist/native-binding.d.ts.map +1 -1
  46. package/dist/native-operations.d.ts +4 -1
  47. package/dist/native-operations.d.ts.map +1 -1
  48. package/dist/native-operations.js +22 -6
  49. package/dist/native-pinned-write-windows.d.ts +8 -0
  50. package/dist/native-pinned-write-windows.d.ts.map +1 -0
  51. package/dist/native-pinned-write-windows.js +92 -0
  52. package/dist/native-pinned-write.d.ts.map +1 -1
  53. package/dist/native-pinned-write.js +136 -127
  54. package/dist/native-staged-file.d.ts +24 -0
  55. package/dist/native-staged-file.d.ts.map +1 -0
  56. package/dist/native-staged-file.js +337 -0
  57. package/dist/native.d.ts.map +1 -1
  58. package/dist/native.js +4 -4
  59. package/dist/opened-realpath.d.ts +2 -0
  60. package/dist/opened-realpath.d.ts.map +1 -1
  61. package/dist/opened-realpath.js +12 -7
  62. package/dist/owner-dacl.d.ts.map +1 -1
  63. package/dist/owner-dacl.js +2 -1
  64. package/dist/permission-exec.d.ts +19 -0
  65. package/dist/permission-exec.d.ts.map +1 -1
  66. package/dist/permission-exec.js +57 -11
  67. package/dist/permissions-public.d.ts +1 -1
  68. package/dist/permissions-public.d.ts.map +1 -1
  69. package/dist/permissions-windows.d.ts +3 -0
  70. package/dist/permissions-windows.d.ts.map +1 -1
  71. package/dist/permissions-windows.js +16 -5
  72. package/dist/permissions.d.ts +5 -0
  73. package/dist/permissions.d.ts.map +1 -1
  74. package/dist/pinned-write.d.ts +6 -0
  75. package/dist/pinned-write.d.ts.map +1 -1
  76. package/dist/pinned-write.js +19 -29
  77. package/dist/private-directory.d.ts.map +1 -1
  78. package/dist/private-directory.js +3 -2
  79. package/dist/root-impl.d.ts.map +1 -1
  80. package/dist/root-impl.js +78 -72
  81. package/dist/root-paths.d.ts +11 -14
  82. package/dist/root-paths.d.ts.map +1 -1
  83. package/dist/root-paths.js +36 -27
  84. package/dist/root-write-verification.d.ts +11 -0
  85. package/dist/root-write-verification.d.ts.map +1 -0
  86. package/dist/root-write-verification.js +91 -0
  87. package/dist/secret-file.d.ts +1 -6
  88. package/dist/secret-file.d.ts.map +1 -1
  89. package/dist/secret-file.js +43 -117
  90. package/dist/secret-read-async.d.ts +1 -1
  91. package/dist/secret-read-async.d.ts.map +1 -1
  92. package/dist/secret-read-async.js +45 -67
  93. package/dist/secret-read-policy.d.ts +13 -0
  94. package/dist/secret-read-policy.d.ts.map +1 -0
  95. package/dist/secret-read-policy.js +28 -0
  96. package/dist/secret.d.ts +2 -1
  97. package/dist/secret.d.ts.map +1 -1
  98. package/dist/secret.js +2 -1
  99. package/dist/secure-file.d.ts.map +1 -1
  100. package/dist/secure-file.js +25 -16
  101. package/dist/staged-directory.d.ts +16 -0
  102. package/dist/staged-directory.d.ts.map +1 -0
  103. package/dist/staged-directory.js +60 -0
  104. package/dist/staged-file-types.d.ts +56 -0
  105. package/dist/staged-file-types.d.ts.map +1 -0
  106. package/dist/staged-file-types.js +1 -0
  107. package/dist/staged-file.d.ts +10 -0
  108. package/dist/staged-file.d.ts.map +1 -0
  109. package/dist/staged-file.js +15 -0
  110. package/dist/strict-file-identity.d.ts +6 -0
  111. package/dist/strict-file-identity.d.ts.map +1 -0
  112. package/dist/strict-file-identity.js +48 -0
  113. package/dist/windows-owner.d.ts +3 -0
  114. package/dist/windows-owner.d.ts.map +1 -1
  115. package/dist/windows-owner.js +10 -2
  116. package/docs/advanced.md +11 -1
  117. package/docs/archive.md +62 -15
  118. package/docs/config.md +7 -0
  119. package/docs/contributing.md +45 -7
  120. package/docs/durability.md +17 -4
  121. package/docs/errors.md +16 -1
  122. package/docs/index.md +3 -1
  123. package/docs/install.md +15 -7
  124. package/docs/migrating-to-0.5.md +7 -7
  125. package/docs/migrating-to-0.6.md +43 -0
  126. package/docs/native-helper.md +15 -5
  127. package/docs/native.md +40 -11
  128. package/docs/path-scope.md +28 -2
  129. package/docs/permissions.md +13 -2
  130. package/docs/root.md +1 -1
  131. package/docs/secret-file.md +8 -0
  132. package/docs/secure-file.md +17 -1
  133. package/docs/security-model.md +15 -1
  134. package/docs/sidecar-lock.md +11 -1
  135. package/docs/staged-file.md +178 -0
  136. package/docs/testing.md +1 -1
  137. package/docs/writing.md +19 -0
  138. package/package.json +15 -8
  139. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  140. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  141. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  142. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  143. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  144. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  145. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { formatErrorDetail } from "./error-detail.js";
3
4
  import { FsSafeError } from "./errors.js";
4
- import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
5
+ import { assertNoNulPathInput, hasNodeErrorCode, isNodeError, isNotFoundPathError, isPathInside, isPathRelativeEscape, } from "./path.js";
5
6
  import { root as openRoot } from "./root.js";
6
7
  function invalidPath(scopeLabel) {
7
8
  return {
@@ -126,7 +127,7 @@ async function resolveNearestExistingPath(targetPath) {
126
127
  async function assertNoSymlinkSegments(params) {
127
128
  const relative = path.relative(params.rootDir, params.targetPath);
128
129
  if (isPathRelativeEscape(relative)) {
129
- throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
130
+ throw new FsSafeError("outside-workspace", `Invalid path: must stay within ${params.scopeLabel}`);
130
131
  }
131
132
  let current = params.rootDir;
132
133
  for (const segment of relative.split(path.sep).filter(Boolean)) {
@@ -134,10 +135,10 @@ async function assertNoSymlinkSegments(params) {
134
135
  try {
135
136
  const stat = await fs.lstat(current);
136
137
  if (stat.isSymbolicLink()) {
137
- throw new Error(`Invalid path: must not traverse symlinks within ${params.scopeLabel}`);
138
+ throw new FsSafeError("symlink", `Invalid path: must not traverse symlinks within ${params.scopeLabel}`);
138
139
  }
139
140
  if (!stat.isDirectory()) {
140
- throw new Error(`Invalid path: existing segment must be a directory within ${params.scopeLabel}`);
141
+ throw new FsSafeError("not-file", `Invalid path: existing segment must be a directory within ${params.scopeLabel}`);
141
142
  }
142
143
  }
143
144
  catch (err) {
@@ -149,28 +150,29 @@ async function assertNoSymlinkSegments(params) {
149
150
  }
150
151
  }
151
152
  export async function ensureDirectoryWithinRoot(params) {
152
- const lexical = resolvePathWithinRoot({
153
- rootDir: params.rootDir,
154
- requestedPath: params.requestedPath,
155
- scopeLabel: params.scopeLabel,
156
- defaultFileName: params.defaultDirName,
157
- });
158
- if (!lexical.ok) {
159
- return lexical;
160
- }
161
- const rootDir = path.resolve(params.rootDir);
162
- const targetPath = path.resolve(lexical.path);
153
+ const scopeLabel = formatErrorDetail(params.scopeLabel.slice(0, 80));
163
154
  try {
155
+ assertNoNulPathInput(params.rootDir);
156
+ assertNoNulPathInput(params.requestedPath);
157
+ if (!params.requestedPath.trim())
158
+ assertNoNulPathInput(params.defaultDirName ?? "");
159
+ const lexical = resolvePathWithinRoot({
160
+ ...params, scopeLabel, defaultFileName: params.defaultDirName,
161
+ });
162
+ if (!lexical.ok)
163
+ return lexical;
164
+ const rootDir = path.resolve(params.rootDir);
165
+ const targetPath = lexical.path;
164
166
  const rootStat = await fs.lstat(rootDir);
165
167
  if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
166
- return invalidPath(params.scopeLabel);
168
+ return invalidPath(scopeLabel);
167
169
  }
168
- await assertNoSymlinkSegments({ rootDir, targetPath, scopeLabel: params.scopeLabel });
170
+ await assertNoSymlinkSegments({ rootDir, targetPath, scopeLabel });
169
171
  const rootReal = await fs.realpath(rootDir);
170
172
  const nearestExistingPath = await resolveNearestExistingPath(targetPath);
171
173
  const nearestExistingReal = await fs.realpath(nearestExistingPath);
172
174
  if (!isPathInside(rootReal, nearestExistingReal)) {
173
- return invalidPath(params.scopeLabel);
175
+ return invalidPath(scopeLabel);
174
176
  }
175
177
  const relative = path.relative(rootDir, targetPath);
176
178
  let current = rootDir;
@@ -180,7 +182,7 @@ export async function ensureDirectoryWithinRoot(params) {
180
182
  try {
181
183
  const stat = await fs.lstat(current);
182
184
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
183
- return invalidPath(params.scopeLabel);
185
+ return invalidPath(scopeLabel);
184
186
  }
185
187
  break;
186
188
  }
@@ -192,10 +194,7 @@ export async function ensureDirectoryWithinRoot(params) {
192
194
  await fs.mkdir(current, { mode: params.mode });
193
195
  }
194
196
  catch (mkdirErr) {
195
- if (isNotFoundPathError(mkdirErr)) {
196
- throw mkdirErr;
197
- }
198
- if (mkdirErr.code === "EEXIST") {
197
+ if (hasNodeErrorCode(mkdirErr, "EEXIST")) {
199
198
  continue;
200
199
  }
201
200
  throw mkdirErr;
@@ -204,17 +203,27 @@ export async function ensureDirectoryWithinRoot(params) {
204
203
  }
205
204
  const currentReal = await fs.realpath(current);
206
205
  if (!isPathInside(rootReal, currentReal)) {
207
- return invalidPath(params.scopeLabel);
206
+ return invalidPath(scopeLabel);
208
207
  }
209
208
  }
210
209
  const targetReal = await fs.realpath(targetPath);
211
210
  if (!isPathInside(rootReal, targetReal)) {
212
- return invalidPath(params.scopeLabel);
211
+ return invalidPath(scopeLabel);
213
212
  }
214
213
  return { ok: true, path: targetPath };
215
214
  }
216
- catch {
217
- return invalidPath(params.scopeLabel);
215
+ catch (cause) {
216
+ if ((cause instanceof FsSafeError && cause.category === "policy") ||
217
+ hasNodeErrorCode(cause, "ENOTDIR")) {
218
+ return invalidPath(scopeLabel);
219
+ }
220
+ // Native messages include unbounded paths; display only bounded errno metadata.
221
+ const code = isNodeError(cause) && typeof cause.code === "string"
222
+ ? formatErrorDetail(cause.code.slice(0, 40)) : "filesystem operation failed";
223
+ const syscall = isNodeError(cause) && typeof cause.syscall === "string"
224
+ ? ` during ${formatErrorDetail(cause.syscall.slice(0, 40))}` : "";
225
+ const diagnostic = new FsSafeError("helper-failed", `Could not prepare ${scopeLabel}: ${code}${syscall}`, { cause });
226
+ return { ok: false, error: diagnostic.message, diagnostic };
218
227
  }
219
228
  }
220
229
  export function resolvePathsWithinRoot(params) {
@@ -0,0 +1,11 @@
1
+ import { type AsyncDirectoryGuard } from "./directory-guard.js";
2
+ import type { PublishedWriteIdentity } from "./pinned-write.js";
3
+ import { type RootContext } from "./root-context.js";
4
+ export declare function verifyAtomicWriteResult(params: {
5
+ root: RootContext;
6
+ targetPath: string;
7
+ fd: number;
8
+ expectedIdentity: PublishedWriteIdentity;
9
+ parentGuard: AsyncDirectoryGuard;
10
+ }): Promise<void>;
11
+ //# sourceMappingURL=root-write-verification.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"root-write-verification.d.ts","sourceRoot":"","sources":["../src/root-write-verification.ts"],"names":[],"mappings":"AAEA,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAK3F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAEhE,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhF,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,IAAI,EAAE,WAAW,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,gBAAgB,EAAE,sBAAsB,CAAC;IACzC,WAAW,EAAE,mBAAmB,CAAC;CAClC,GAAG,OAAO,CAAC,IAAI,CAAC,CA6EhB"}
@@ -0,0 +1,91 @@
1
+ import fsSync, {} from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { assertAsyncDirectoryGuard } from "./directory-guard.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { sameFileIdentity } from "./file-identity.js";
6
+ import { resolveOpenedFileRealPathForFd } from "./opened-realpath.js";
7
+ import { assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path.js";
8
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
9
+ import { assertRootIdentityCurrent } from "./root-context.js";
10
+ import { fileNotFoundError, hardlinkedPathNotAllowedError, outsideWorkspaceError } from "./root-errors.js";
11
+ export async function verifyAtomicWriteResult(params) {
12
+ let needsPathOpen = false;
13
+ const assertFile = (stat) => {
14
+ if (stat.isSymbolicLink()) {
15
+ throw new FsSafeError("symlink", "symlink not allowed");
16
+ }
17
+ if (!stat.isFile()) {
18
+ throw new FsSafeError("not-file", "not a file");
19
+ }
20
+ if (!sameFileIdentity(stat, params.expectedIdentity)) {
21
+ throw new FsSafeError("path-mismatch", "path changed during write");
22
+ }
23
+ if (stat.nlink > 1) {
24
+ throw hardlinkedPathNotAllowedError();
25
+ }
26
+ };
27
+ const assertPath = (stat) => {
28
+ assertFile(stat);
29
+ needsPathOpen ||= process.platform === "win32" && (stat.dev === 0n || stat.ino === 0n);
30
+ };
31
+ const assertDescriptor = (fd = params.fd) => {
32
+ const stat = fsSync.fstatSync(fd, { bigint: true });
33
+ // Only Windows pathname stats may have unknown identity; fd stats must match exactly.
34
+ if (params.expectedIdentity.dev !== stat.dev || params.expectedIdentity.ino !== stat.ino) {
35
+ throw new FsSafeError("path-mismatch", "descriptor changed during write");
36
+ }
37
+ assertFile(stat);
38
+ return stat;
39
+ };
40
+ try {
41
+ // This descriptor remains owned by the writer, even when final mode forbids opens.
42
+ const stat = assertDescriptor();
43
+ assertPath(await fs.lstat(params.targetPath, { bigint: true }));
44
+ const realPath = await resolveOpenedFileRealPathForFd(params.fd, stat, params.targetPath);
45
+ assertPath(await fs.stat(realPath, { bigint: true }));
46
+ if (!isPathInside(params.root.rootWithSep, realPath)) {
47
+ throw outsideWorkspaceError();
48
+ }
49
+ await assertAsyncDirectoryGuard(params.parentGuard);
50
+ await assertRootIdentityCurrent(params.root);
51
+ // Recheck after canonical resolution and directory checks, including late links.
52
+ assertPath(await fs.lstat(params.targetPath, { bigint: true }));
53
+ assertDescriptor();
54
+ if (needsPathOpen) {
55
+ // A retained fd cannot prove that an opaque Windows pathname still names it.
56
+ // Reopen only for publication verification: the writer's exact identity is
57
+ // independent proof unavailable to ordinary readers. Never read any bytes.
58
+ assertNoUnsafeDeviceReadPath(params.targetPath);
59
+ const opened = await fs.open(params.targetPath, resolveReadOpenFlags()).catch((error) => {
60
+ if (isSymlinkOpenError(error)) {
61
+ throw new FsSafeError("symlink", "symlink open blocked", { cause: error });
62
+ }
63
+ if (hasNodeErrorCode(error, "EISDIR")) {
64
+ throw new FsSafeError("not-file", "not a file");
65
+ }
66
+ throw error;
67
+ });
68
+ try {
69
+ const reopenedStat = assertDescriptor(opened.fd);
70
+ assertPath(await fs.lstat(params.targetPath, { bigint: true }));
71
+ const reopenedPath = await resolveOpenedFileRealPathForFd(opened.fd, reopenedStat, params.targetPath);
72
+ assertPath(await fs.stat(reopenedPath, { bigint: true }));
73
+ if (!isPathInside(params.root.rootWithSep, reopenedPath)) {
74
+ throw outsideWorkspaceError();
75
+ }
76
+ await assertAsyncDirectoryGuard(params.parentGuard);
77
+ await assertRootIdentityCurrent(params.root);
78
+ assertPath(await fs.lstat(params.targetPath, { bigint: true }));
79
+ assertDescriptor(opened.fd);
80
+ }
81
+ finally {
82
+ await opened.close().catch(() => undefined);
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ if (isNotFoundPathError(error))
88
+ throw fileNotFoundError();
89
+ throw error;
90
+ }
91
+ }
@@ -1,11 +1,6 @@
1
- export declare const DEFAULT_SECRET_FILE_MAX_BYTES: number;
1
+ import { type SecretFileReadOptions } from "./secret-read-policy.js";
2
2
  export declare const PRIVATE_SECRET_DIR_MODE = 448;
3
3
  export declare const PRIVATE_SECRET_FILE_MODE = 384;
4
- export type SecretFileReadOptions = {
5
- maxBytes?: number;
6
- rejectSymlink?: boolean;
7
- rejectHardlinks?: boolean;
8
- };
9
4
  export declare function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;
10
5
  export declare function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;
11
6
  type SecretFileWriteParams = {
@@ -1 +1 @@
1
- {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAaA,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;AAyIF,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,KAAK,qBAAqB,GAAG;IAC3B,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,CAAC;AAkEF,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAKxF;AAED,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAezF"}
1
+ {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAWA,OAAO,EAML,KAAK,qBAAqB,EAC3B,MAAM,yBAAyB,CAAC;AAIjC,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAsER;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAUpB;AAoID,KAAK,qBAAqB,GAAG;IAC3B,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,CAAC;AAkEF,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAKxF;AAED,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAezF"}
@@ -9,155 +9,81 @@ import { sameFileIdentity } from "./file-identity.js";
9
9
  import { resolveHomeRelativePath } from "./home-dir.js";
10
10
  import { openPinnedFileSync } from "./pinned-open.js";
11
11
  import { runPinnedWriteHelper } from "./pinned-write.js";
12
+ import { assertSecretFilePreview, DEFAULT_SECRET_FILE_MAX_BYTES, secretPathErrorCode, secretReadError, trimSecretFileContent, } from "./secret-read-policy.js";
13
+ import { inspectFileIdentitySync } from "./strict-file-identity.js";
12
14
  import { serializePathWrite } from "./write-queue.js";
13
- export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
14
15
  export const PRIVATE_SECRET_DIR_MODE = 0o700;
15
16
  export const PRIVATE_SECRET_FILE_MODE = 0o600;
16
- function normalizeSecretReadError(error) {
17
- return error instanceof Error ? error : new Error(String(error));
18
- }
19
- function secretPathErrorCode(error) {
20
- const code = error.code;
21
- return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
22
- }
23
- function resolveUserPath(input) {
24
- return resolveHomeRelativePath(input);
25
- }
26
- function readSecretFileOutcomeSync(filePath, label, options = {}) {
27
- const trimmedPath = filePath.trim();
28
- const resolvedPath = resolveUserPath(trimmedPath);
17
+ export function readSecretFileSync(filePath, label, options = {}) {
18
+ const resolvedPath = resolveHomeRelativePath(filePath.trim());
29
19
  if (!resolvedPath) {
30
- return { ok: false, code: "invalid-path", message: `${label} file path is empty.` };
20
+ throw new FsSafeError("invalid-path", `${label} file path is empty.`, { cause: undefined });
31
21
  }
32
22
  const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES;
23
+ function inspectInput(symlinkMessage) {
24
+ const stat = options.rejectSymlink
25
+ ? fs.lstatSync(resolvedPath, { bigint: true })
26
+ : fs.statSync(resolvedPath, { bigint: true });
27
+ if (options.rejectSymlink && stat.isSymbolicLink()) {
28
+ throw new FsSafeError("symlink", symlinkMessage);
29
+ }
30
+ return stat;
31
+ }
33
32
  let previewStat;
34
33
  try {
35
- previewStat = fs.lstatSync(resolvedPath);
34
+ previewStat = inspectFileIdentitySync(() => inspectInput(`${label} file at ${resolvedPath} must not be a symlink.`));
36
35
  }
37
36
  catch (error) {
38
- const normalized = normalizeSecretReadError(error);
39
- return {
40
- ok: false,
41
- code: secretPathErrorCode(error),
42
- error: normalized,
43
- message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
44
- };
45
- }
46
- if (previewStat.isSymbolicLink()) {
47
- if (!options.rejectSymlink) {
48
- try {
49
- previewStat = fs.statSync(resolvedPath);
50
- }
51
- catch (error) {
52
- const normalized = normalizeSecretReadError(error);
53
- return {
54
- ok: false,
55
- code: secretPathErrorCode(error),
56
- error: normalized,
57
- message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
58
- };
59
- }
60
- }
61
- else {
62
- return {
63
- ok: false,
64
- code: "symlink",
65
- message: `${label} file at ${resolvedPath} must not be a symlink.`,
66
- };
67
- }
68
- }
69
- if (!previewStat.isFile()) {
70
- return {
71
- ok: false,
72
- code: "not-file",
73
- message: `${label} file at ${resolvedPath} must be a regular file.`,
74
- };
75
- }
76
- if (options.rejectHardlinks !== false && previewStat.nlink > 1) {
77
- return {
78
- ok: false,
79
- code: "hardlink",
80
- message: `${label} file at ${resolvedPath} must not be hardlinked.`,
81
- };
82
- }
83
- if (previewStat.size > maxBytes) {
84
- return {
85
- ok: false,
86
- code: "too-large",
87
- message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`,
88
- };
37
+ throw secretReadError(error instanceof FsSafeError ? error.code : secretPathErrorCode(error), "inspect", label, resolvedPath, error);
89
38
  }
39
+ assertSecretFilePreview(previewStat, label, resolvedPath, maxBytes, options.rejectHardlinks !== false);
90
40
  const opened = openPinnedFileSync({
91
41
  filePath: resolvedPath,
92
42
  rejectPathSymlink: options.rejectSymlink,
93
43
  rejectHardlinks: options.rejectHardlinks !== false,
94
44
  });
95
45
  if (!opened.ok) {
96
- const error = normalizeSecretReadError(opened.reason === "validation" ? new Error("security validation failed") : opened.error);
97
- return {
98
- ok: false,
99
- code: opened.reason === "path" ? "not-found" : "path-mismatch",
100
- error,
101
- message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
102
- };
46
+ throw secretReadError(opened.reason === "path" ? "not-found" : "path-mismatch", "read", label, resolvedPath, opened.reason === "validation" ? new Error("security validation failed") : opened.error);
103
47
  }
48
+ let raw;
104
49
  try {
105
- if (!sameFileIdentity(previewStat, opened.stat)) {
106
- const error = new FsSafeError("path-mismatch", "security validation failed");
107
- return {
108
- ok: false,
109
- code: "path-mismatch",
110
- error,
111
- message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
112
- };
113
- }
114
- const raw = readFileDescriptorBoundedSync(opened.fd, maxBytes).toString("utf8");
115
- const secret = raw.trim();
116
- if (!secret) {
117
- return {
118
- ok: false,
119
- code: "invalid-path",
120
- message: `${label} file at ${resolvedPath} is empty.`,
121
- };
122
- }
123
- return { ok: true, secret };
50
+ const openedIdentity = inspectFileIdentitySync(() => {
51
+ const stat = fs.fstatSync(opened.fd, { bigint: true });
52
+ if (!stat.isFile() || (options.rejectHardlinks !== false && stat.nlink > 1n)) {
53
+ throw new FsSafeError("path-mismatch", "security validation failed");
54
+ }
55
+ return stat;
56
+ }, previewStat);
57
+ inspectFileIdentitySync(() => {
58
+ const stat = fs.lstatSync(opened.path, { bigint: true });
59
+ if (!stat.isFile() || (options.rejectHardlinks !== false && stat.nlink > 1n)) {
60
+ throw new FsSafeError("path-mismatch", "security validation failed");
61
+ }
62
+ return stat;
63
+ }, openedIdentity);
64
+ inspectFileIdentitySync(() => inspectInput("secret path became a symlink"), openedIdentity);
65
+ raw = readFileDescriptorBoundedSync(opened.fd, maxBytes).toString("utf8");
124
66
  }
125
67
  catch (error) {
126
- const normalized = normalizeSecretReadError(error);
127
- return {
128
- ok: false,
129
- code: error instanceof FsSafeError ? error.code : "read-failed",
130
- error: normalized,
131
- message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
132
- };
68
+ throw secretReadError(error instanceof FsSafeError ? error.code : "read-failed", "read", label, resolvedPath, error);
133
69
  }
134
70
  finally {
135
71
  fs.closeSync(opened.fd);
136
72
  }
137
- }
138
- export function readSecretFileSync(filePath, label, options = {}) {
139
- const result = readSecretFileOutcomeSync(filePath, label, options);
140
- if (result.ok) {
141
- return result.secret;
142
- }
143
- throw new FsSafeError(result.code, result.message, {
144
- cause: result.error,
145
- });
73
+ return trimSecretFileContent(raw, label, resolvedPath);
146
74
  }
147
75
  export function tryReadSecretFileSync(filePath, label, options = {}) {
148
76
  if (!filePath?.trim()) {
149
77
  return undefined;
150
78
  }
151
- const result = readSecretFileOutcomeSync(filePath, label, options);
152
- if (result.ok) {
153
- return result.secret;
79
+ try {
80
+ return readSecretFileSync(filePath, label, options);
154
81
  }
155
- if (result.code === "not-found") {
156
- return undefined;
82
+ catch (error) {
83
+ if (error instanceof FsSafeError && error.code === "not-found")
84
+ return undefined;
85
+ throw error;
157
86
  }
158
- throw new FsSafeError(result.code, result.message, {
159
- cause: result.error,
160
- });
161
87
  }
162
88
  function isRelativeEscape(relativePath) {
163
89
  return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
@@ -1,4 +1,4 @@
1
- import { type SecretFileReadOptions } from "./secret-file.js";
1
+ import { type SecretFileReadOptions } from "./secret-read-policy.js";
2
2
  export declare function readSecretFile(filePath: string, label: string, options?: SecretFileReadOptions): Promise<string>;
3
3
  export declare function tryReadSecretFile(filePath: string | undefined, label: string, options?: SecretFileReadOptions): Promise<string | undefined>;
4
4
  //# sourceMappingURL=secret-read-async.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secret-read-async.d.ts","sourceRoot":"","sources":["../src/secret-read-async.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,kBAAkB,CAAC;AA0F1B,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAM7B"}
1
+ {"version":3,"file":"secret-read-async.d.ts","sourceRoot":"","sources":["../src/secret-read-async.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,qBAAqB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,MAAM,CAAC,CAgEjB;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ7B"}
@@ -2,98 +2,76 @@ import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { readFileHandleBounded } from "./bounded-read.js";
4
4
  import { FsSafeError } from "./errors.js";
5
- import { sameFileIdentity } from "./file-identity.js";
6
5
  import { resolveHomeRelativePath } from "./home-dir.js";
7
- import { DEFAULT_SECRET_FILE_MAX_BYTES, } from "./secret-file.js";
8
- function pathErrorCode(error) {
9
- const code = error.code;
10
- return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
11
- }
12
- async function readSecretFileOutcome(filePath, label, options) {
6
+ import { inspectFileIdentity } from "./strict-file-identity.js";
7
+ import { assertSecretFilePreview, DEFAULT_SECRET_FILE_MAX_BYTES, secretPathErrorCode, secretReadError, trimSecretFileContent, } from "./secret-read-policy.js";
8
+ export async function readSecretFile(filePath, label, options = {}) {
13
9
  const resolvedPath = resolveHomeRelativePath(filePath.trim());
14
10
  if (!resolvedPath) {
15
- return { ok: false, code: "invalid-path", message: `${label} file path is empty.` };
11
+ throw new FsSafeError("invalid-path", `${label} file path is empty.`, { cause: undefined });
16
12
  }
17
13
  const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES;
14
+ async function inspectInput(symlinkMessage) {
15
+ const stat = options.rejectSymlink
16
+ ? await fs.lstat(resolvedPath, { bigint: true })
17
+ : await fs.stat(resolvedPath, { bigint: true });
18
+ if (options.rejectSymlink && stat.isSymbolicLink()) {
19
+ throw new FsSafeError("symlink", symlinkMessage);
20
+ }
21
+ return stat;
22
+ }
18
23
  let previewStat;
19
24
  try {
20
- previewStat = await fs.lstat(resolvedPath);
21
- if (previewStat.isSymbolicLink()) {
22
- if (options.rejectSymlink) {
23
- return { ok: false, code: "symlink", message: `${label} file at ${resolvedPath} must not be a symlink.` };
24
- }
25
- previewStat = await fs.stat(resolvedPath);
26
- }
25
+ previewStat = await inspectFileIdentity(() => inspectInput(`${label} file at ${resolvedPath} must not be a symlink.`));
27
26
  }
28
27
  catch (error) {
29
- const normalized = error instanceof Error ? error : new Error(String(error));
30
- return {
31
- ok: false,
32
- code: pathErrorCode(error),
33
- error: normalized,
34
- message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
35
- };
36
- }
37
- if (!previewStat.isFile()) {
38
- return { ok: false, code: "not-file", message: `${label} file at ${resolvedPath} must be a regular file.` };
39
- }
40
- if (options.rejectHardlinks !== false && previewStat.nlink > 1) {
41
- return { ok: false, code: "hardlink", message: `${label} file at ${resolvedPath} must not be hardlinked.` };
42
- }
43
- if (previewStat.size > maxBytes) {
44
- return { ok: false, code: "too-large", message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.` };
28
+ throw secretReadError(error instanceof FsSafeError ? error.code : secretPathErrorCode(error), "inspect", label, resolvedPath, error);
45
29
  }
30
+ assertSecretFilePreview(previewStat, label, resolvedPath, maxBytes, options.rejectHardlinks !== false);
46
31
  let handle;
32
+ let raw;
47
33
  try {
48
34
  const realPath = await fs.realpath(resolvedPath);
49
35
  const noFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
50
36
  ? fsSync.constants.O_NOFOLLOW
51
37
  : 0;
52
38
  handle = await fs.open(realPath, fsSync.constants.O_RDONLY | noFollow);
53
- const openedStat = await handle.stat();
54
- const pathStat = await fs.lstat(realPath);
55
- if (!openedStat.isFile() ||
56
- !pathStat.isFile() ||
57
- !sameFileIdentity(previewStat, openedStat) ||
58
- !sameFileIdentity(pathStat, openedStat) ||
59
- (options.rejectHardlinks !== false && openedStat.nlink > 1)) {
60
- throw new FsSafeError("path-mismatch", "security validation failed");
61
- }
62
- const secret = (await readFileHandleBounded(handle, maxBytes)).toString("utf8").trim();
63
- return secret
64
- ? { ok: true, secret }
65
- : { ok: false, code: "invalid-path", message: `${label} file at ${resolvedPath} is empty.` };
39
+ const openedHandle = handle;
40
+ const openedStat = await inspectFileIdentity(async () => {
41
+ const stat = await openedHandle.stat({ bigint: true });
42
+ if (!stat.isFile() || (options.rejectHardlinks !== false && stat.nlink > 1n)) {
43
+ throw new FsSafeError("path-mismatch", "security validation failed");
44
+ }
45
+ return stat;
46
+ }, previewStat);
47
+ await inspectFileIdentity(async () => {
48
+ const stat = await fs.lstat(realPath, { bigint: true });
49
+ if (!stat.isFile())
50
+ throw new FsSafeError("path-mismatch", "security validation failed");
51
+ return stat;
52
+ }, openedStat);
53
+ await inspectFileIdentity(() => inspectInput("secret path became a symlink"), openedStat);
54
+ raw = (await readFileHandleBounded(handle, maxBytes)).toString("utf8");
66
55
  }
67
56
  catch (error) {
68
- const normalized = error instanceof Error ? error : new Error(String(error));
69
- return {
70
- ok: false,
71
- code: error instanceof FsSafeError
72
- ? error.code
73
- : pathErrorCode(error) === "not-found"
74
- ? "not-found"
75
- : "read-failed",
76
- error: normalized,
77
- message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
78
- };
57
+ throw secretReadError(error instanceof FsSafeError
58
+ ? error.code
59
+ : secretPathErrorCode(error) === "not-found" ? "not-found" : "read-failed", "read", label, resolvedPath, error);
79
60
  }
80
61
  finally {
81
62
  await handle?.close().catch(() => undefined);
82
63
  }
83
- }
84
- export async function readSecretFile(filePath, label, options = {}) {
85
- const result = await readSecretFileOutcome(filePath, label, options);
86
- if (result.ok)
87
- return result.secret;
88
- throw new FsSafeError(result.code, result.message, { cause: result.error });
64
+ return trimSecretFileContent(raw, label, resolvedPath);
89
65
  }
90
66
  export async function tryReadSecretFile(filePath, label, options = {}) {
91
67
  if (!filePath?.trim())
92
68
  return undefined;
93
- const result = await readSecretFileOutcome(filePath, label, options);
94
- if (result.ok)
95
- return result.secret;
96
- if (result.code === "not-found")
97
- return undefined;
98
- throw new FsSafeError(result.code, result.message, { cause: result.error });
69
+ try {
70
+ return await readSecretFile(filePath, label, options);
71
+ }
72
+ catch (error) {
73
+ if (error instanceof FsSafeError && error.code === "not-found")
74
+ return undefined;
75
+ throw error;
76
+ }
99
77
  }
@@ -0,0 +1,13 @@
1
+ import type { BigIntStats } from "node:fs";
2
+ import { FsSafeError, type FsSafeErrorCode } from "./errors.js";
3
+ export declare const DEFAULT_SECRET_FILE_MAX_BYTES: number;
4
+ export type SecretFileReadOptions = {
5
+ maxBytes?: number;
6
+ rejectSymlink?: boolean;
7
+ rejectHardlinks?: boolean;
8
+ };
9
+ export declare function secretPathErrorCode(error: unknown): FsSafeErrorCode;
10
+ export declare function secretReadError(code: FsSafeErrorCode, action: "inspect" | "read", label: string, resolvedPath: string, error: unknown): FsSafeError;
11
+ export declare function assertSecretFilePreview(stat: BigIntStats, label: string, resolvedPath: string, maxBytes: number, rejectHardlinks: boolean): void;
12
+ export declare function trimSecretFileContent(raw: string, label: string, resolvedPath: string): string;
13
+ //# sourceMappingURL=secret-read-policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-read-policy.d.ts","sourceRoot":"","sources":["../src/secret-read-policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEhE,eAAO,MAAM,6BAA6B,QAAY,CAAC;AAEvD,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CAGnE;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,SAAS,GAAG,MAAM,EAC1B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,OAAO,GACb,WAAW,CAGb;AAED,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,OAAO,GACvB,IAAI,CAUN;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAM9F"}