@openclaw/fs-safe 0.18.2 → 0.19.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 (47) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/archive-durability.js +1 -1
  3. package/dist/archive-merge.js +1 -1
  4. package/dist/archive-staging.js +3 -1
  5. package/dist/file-lock-sync-root-held.d.ts +4 -11
  6. package/dist/file-lock-sync-root-io.d.ts +1 -4
  7. package/dist/file-lock-sync-root.d.ts +2 -4
  8. package/dist/file-store-boundary.d.ts +3 -7
  9. package/dist/file-store-boundary.js +7 -10
  10. package/dist/file-store-prune.js +3 -2
  11. package/dist/file-store.js +19 -21
  12. package/dist/guest-native-python.js +23 -31
  13. package/dist/guest.js +21 -12
  14. package/dist/json-durable-queue.js +2 -6
  15. package/dist/local-file-descriptor.d.ts +2 -5
  16. package/dist/local-roots.d.ts +2 -7
  17. package/dist/native-binding.d.ts +12 -13
  18. package/dist/pinned-mutation-shared-route.d.ts +2 -8
  19. package/dist/root-context.js +3 -2
  20. package/dist/root-move-noreplace.d.ts +2 -7
  21. package/dist/root-paths.d.ts +2 -6
  22. package/dist/root-remove-identity.d.ts +1 -3
  23. package/dist/root-walk.js +8 -3
  24. package/dist/root-write-admission.js +1 -4
  25. package/dist/root-write-complete-parent.d.ts +2 -0
  26. package/dist/root-write-complete-parent.js +1 -1
  27. package/dist/secret-file.d.ts +6 -2
  28. package/dist/secret-file.js +1 -0
  29. package/dist/secure-file-windows.js +1 -5
  30. package/dist/sidecar-lock-admission-parser.d.ts +1 -2
  31. package/dist/sidecar-lock-handle.d.ts +2 -8
  32. package/dist/sidecar-lock-policy.d.ts +2 -7
  33. package/dist/sidecar-lock-stale-admission.d.ts +1 -5
  34. package/dist/test-hooks.d.ts +1 -1
  35. package/dist/windows-security-command.js +1 -4
  36. package/dist/windows-security-facts.d.ts +1 -0
  37. package/dist/windows-security-facts.js +2 -2
  38. package/docs/archive.md +2 -0
  39. package/docs/copy.md +2 -0
  40. package/docs/file-store.md +5 -0
  41. package/docs/guest.md +7 -1
  42. package/docs/native-helper.md +6 -0
  43. package/docs/root.md +10 -1
  44. package/docs/secret-file.md +10 -0
  45. package/docs/walk.md +12 -0
  46. package/docs/writing.md +11 -0
  47. package/package.json +8 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.19.0 - 2026-09-24
6
+
7
+ ### Highlights
8
+
9
+ - **Strict secret-file durability:** `createSecretFileAtomic()` accepts `durable: "file"` to require every file flush to succeed, including on `EPERM`; parent-directory synchronization remains best effort. ([#644](https://github.com/openclaw/fs-safe/pull/644))
10
+ - **Literal `~` names:** keep FileStore keys, absolute Root reads, discovered walk entries, and ZIP/TAR entries literal instead of treating them as home-directory shorthand. Reads, writes, removal, pruning, extraction, and durable publication select the intended entry. ([#633](https://github.com/openclaw/fs-safe/pull/633))
11
+ - **Native Unix descriptor safety:** reject negative descriptors in low-level root, query, hash, copy, clone, staging, and cleanup calls without Rust panics or working-directory operations. Modern macOS beneath opens report `EBADF` for negative roots instead of `EIO`. ([#640](https://github.com/openclaw/fs-safe/pull/640), [#643](https://github.com/openclaw/fs-safe/pull/643), [#646](https://github.com/openclaw/fs-safe/pull/646))
12
+
13
+ ### Fixes
14
+
15
+ - **Guest directory permissions:** cross-device moves preserve mode `000`, including nested directories, without changing other modes' umask behavior. Restore top-level permissions through the retained directory descriptor and preserve published entries on failure. Thanks @SebTardif. ([#616](https://github.com/openclaw/fs-safe/pull/616))
16
+ - **ReFS clone cleanup:** remove ordinary partial output when Windows rejects the ignore-readonly deletion flag. Keep readonly attributes intact and include cleanup failures in the original clone error; readonly files or other processes' open handles can still leave output behind. ([#648](https://github.com/openclaw/fs-safe/pull/648))
17
+
18
+ ### Compatibility and documentation
19
+
20
+ - Secret creation's default and boolean durability options retain their behavior, and `writeSecretFileAtomic()` remains boolean-only. Strict file synchronization preserves existing publication and cleanup semantics: a failure after publication can leave a complete file present. ([#644](https://github.com/openclaw/fs-safe/pull/644))
21
+ - `Root.walk("~")` and `Root.walk("~/dir")` expand home shorthand when iteration starts and return admitted canonical paths relative to the Root. Use `./~/dir` for a literal tilde directory, and prefix returned entry paths with `./` when passing them to another Root method. Relative Root `~/name` inputs still expand home; FileStore keys do not. ([#633](https://github.com/openclaw/fs-safe/pull/633))
22
+ - Guest moves still require OS permission to read mode-000 source directories; they do not widen source permissions. If permission restoration fails after publication, the source and published copy remain for caller reconciliation. ([#616](https://github.com/openclaw/fs-safe/pull/616))
23
+ - Clarify that `Root.append()` and `openWritable()` creation modes remain subject to the process umask and do not chmod existing files. This documents existing behavior. ([#636](https://github.com/openclaw/fs-safe/pull/636))
24
+
5
25
  ## 0.18.2 - 2026-09-22
6
26
 
7
27
  ### Fixes
@@ -78,7 +78,7 @@ export async function finalizeArchivePublication(params) {
78
78
  const env_1 = { stack: [], error: void 0, hasError: false };
79
79
  try {
80
80
  await assertGuards(file.guards);
81
- const opened = __addDisposableResource(env_1, await params.targetRoot.open(file.relativePath, { hardlinks: "reject", symlinks: "reject" })
81
+ const opened = __addDisposableResource(env_1, await params.targetRoot.open(`./${file.relativePath}`, { hardlinks: "reject", symlinks: "reject" })
82
82
  .catch((error) => {
83
83
  if (error instanceof FsSafeError && (error.code === "hardlink" || error.code === "path-alias")) {
84
84
  throw createArchiveSymlinkTraversalError(file.relativePath);
@@ -250,7 +250,7 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
250
250
  }
251
251
  };
252
252
  }
253
- await targetRoot.copyIn(relPath, sourcePath, options);
253
+ await targetRoot.copyIn(`./${relPath}`, sourcePath, options);
254
254
  check();
255
255
  await assertGuards();
256
256
  await assertResolvedInsideDestination({
@@ -133,7 +133,9 @@ export async function assertResolvedInsideDestination(params) {
133
133
  }
134
134
  async function mkdirArchiveOutput(params) {
135
135
  try {
136
- await params.targetRoot.mkdir(params.relativePath);
136
+ const relativePath = params.relativePath;
137
+ // Archive names are literal; retain Root.mkdir's admission without home expansion.
138
+ await params.targetRoot.mkdir(relativePath === "~" || relativePath.startsWith("~/") ? `./${relativePath}` : relativePath);
137
139
  }
138
140
  catch (error) {
139
141
  if (error instanceof FsSafeError) {
@@ -1,5 +1,6 @@
1
+ import { type SyncHeldLock } from "./file-lock-sync-admission.js";
2
+ import type { SidecarLockOptionFields } from "./sidecar-lock-types.js";
1
3
  import type { FileLockSyncHandle } from "./file-lock-sync.js";
2
- import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
3
4
  import { type FileLockSyncRootAuthority, type FileLockSyncRootPath } from "./file-lock-sync-root.js";
4
5
  import { type FileLockSyncRootFileReceipt, type FileLockSyncRootSnapshot } from "./file-lock-sync-root-io.js";
5
6
  type RootSyncHeldLockReleaseState = "active" | "releasing" | "exit-cleaning" | "released";
@@ -7,22 +8,14 @@ type RootSyncHeldLockHandleDisposition = {
7
8
  held: RootSyncHeldLock;
8
9
  released: boolean;
9
10
  };
10
- export type RootSyncHeldLock = {
11
+ export type RootSyncHeldLock = SidecarLockOptionFields<SyncHeldLock & {
11
12
  deferredExitReleases?: Set<RootSyncHeldLockHandleDisposition>;
12
- fd: number | undefined;
13
- lockPath: string;
14
- normalizedTargetPath: string;
15
- parsePayload?: (raw: string) => unknown;
16
- refCount: number;
17
- reentrantOwner?: string;
18
13
  releaseState: RootSyncHeldLockReleaseState;
19
14
  revision: number;
20
15
  rootAuthority: FileLockSyncRootAuthority;
21
16
  rootPath: FileLockSyncRootPath;
22
17
  rootReceipt: FileLockSyncRootFileReceipt;
23
- snapshot: SidecarLockSnapshot;
24
- timer?: NodeJS.Timeout;
25
- };
18
+ }>;
26
19
  export declare function readRootSidecarSnapshotSync(rootPath: FileLockSyncRootPath, parsePayload?: (raw: string) => unknown, onOpenFailure?: (error: unknown) => void, expectedReceipt?: FileLockSyncRootFileReceipt): FileLockSyncRootSnapshot | null;
27
20
  export declare function getRootSyncHeldLocks(): Map<string, RootSyncHeldLock>;
28
21
  export declare function ensureRootSyncExitCleanupRegistered(): void;
@@ -14,10 +14,7 @@ export type FileLockSyncRootFileReceipt = Readonly<{
14
14
  identity: ExactIdentity;
15
15
  parent: DirectoryReceipt;
16
16
  }>;
17
- export type FileLockSyncRootDirectoryReceipt = Readonly<{
18
- identity: ExactIdentity;
19
- parent: DirectoryReceipt;
20
- }>;
17
+ export type FileLockSyncRootDirectoryReceipt = FileLockSyncRootFileReceipt;
21
18
  export type FileLockSyncRootDiskSnapshot = {
22
19
  ownershipToken?: never;
23
20
  payload: unknown;
@@ -1,3 +1,4 @@
1
+ import { type DenyMutationPolicy } from "./deny-mutations.js";
1
2
  import { type RootContext } from "./root-context.js";
2
3
  import type { Root } from "./root-impl.js";
3
4
  import type { RootDefaults } from "./root-options.js";
@@ -7,10 +8,7 @@ export type FileLockSyncRootAuthority = Readonly<{
7
8
  adapter: object;
8
9
  context: RootContext;
9
10
  assertBeforeMutation?: () => void;
10
- denyMutations?: Readonly<{
11
- paths?: readonly string[];
12
- prefixes?: readonly string[];
13
- }>;
11
+ denyMutations?: Readonly<DenyMutationPolicy>;
14
12
  hardlinks?: RootDefaults["hardlinks"];
15
13
  mutationPolicy: RootPathResolutionPolicy;
16
14
  readPolicy: RootPathResolutionPolicy;
@@ -1,5 +1,5 @@
1
1
  import type { Readable } from "node:stream";
2
- import { type SyncStoreDirectoryReceipt } from "./file-store-sync-directory.js";
2
+ import { ensureSyncStoreDirectory, type SyncStoreDirectoryReceipt } from "./file-store-sync-directory.js";
3
3
  import { type Root } from "./root.js";
4
4
  import { prepareSecretFileWrite } from "./secret-file.js";
5
5
  export type SyncParentGuard = SyncStoreDirectoryReceipt;
@@ -23,12 +23,8 @@ export declare function ensureParentSync(params: {
23
23
  filePath: string;
24
24
  mode: number;
25
25
  }): SyncParentGuard;
26
- export declare function ensureStoreDirectorySync(params: {
27
- rootDir: string;
28
- targetDir: string;
29
- mode: number;
30
- messagePrefix: "private store" | "store";
31
- }): SyncParentGuard;
26
+ export declare function ensureStoreDirectorySync(params: Parameters<typeof ensureSyncStoreDirectory>[0]): SyncParentGuard;
27
+ export declare function literalStoreRootPath(relativePath: string): string;
32
28
  export declare function assertRelativePath(relativePath: string): string;
33
29
  export declare function resolveStorePath(rootDir: string, relativePath: string): string;
34
30
  export declare function assertFileStoreMaxBytes(size: number, limit: number | undefined): void;
@@ -7,8 +7,7 @@ import { FsSafeError } from "./errors.js";
7
7
  import { assertSyncStoreDirectoryReceipt, ensureSyncStoreDirectory, } from "./file-store-sync-directory.js";
8
8
  import { isPathInside, splitSafeRelativePath } from "./path.js";
9
9
  import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
10
- import { ensureTrailingSep } from "./root-context.js";
11
- import { RootHandle } from "./root-impl.js";
10
+ import { rootFromDirectoryGuard } from "./root-impl.js";
12
11
  import { prepareSecretFileWrite } from "./secret-file.js";
13
12
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
14
13
  import { recursiveMkdirPath } from "./recursive-mkdir-path.js";
@@ -16,7 +15,7 @@ import { readRegularFile } from "./regular-file.js";
16
15
  import { errorCauseOptions } from "./root-errors.js";
17
16
  import { assertNoWindowsPathAlias } from "./windows-path-alias.js";
18
17
  export async function ensureParentInRoot(scopedRoot, relativePath, mode) {
19
- const parent = path.posix.dirname(relativePath);
18
+ const parent = literalStoreRootPath(path.posix.dirname(relativePath));
20
19
  if (parent === ".") {
21
20
  return;
22
21
  }
@@ -32,13 +31,7 @@ export async function openWritableStoreRoot(params) {
32
31
  export async function openPrivateStoreLockRoot(params) {
33
32
  const { parentGuard } = await prepareSecretFileWrite(params);
34
33
  // Bind to the admitted parent, never resolve a replacement into a fresh capability.
35
- return new RootHandle({
36
- rootDir: parentGuard.dir,
37
- rootGuard: { dir: parentGuard.realPath, realPath: parentGuard.realPath, stat: parentGuard.stat },
38
- rootReal: parentGuard.realPath,
39
- rootWithSep: ensureTrailingSep(parentGuard.realPath),
40
- rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
41
- }, { hardlinks: "reject" });
34
+ return rootFromDirectoryGuard(parentGuard, { hardlinks: "reject" });
42
35
  }
43
36
  async function chmodDirectoryInRootBestEffort(scopedRoot, relativePath, mode) {
44
37
  const dirPath = await scopedRoot.resolve(relativePath);
@@ -120,6 +113,10 @@ export function ensureStoreDirectorySync(params) {
120
113
  assertSyncStoreDirectoryReceipt(guard);
121
114
  return guard;
122
115
  }
116
+ // Store keys and directory-entry names are literal, unlike Root's home syntax.
117
+ export function literalStoreRootPath(relativePath) {
118
+ return relativePath === "~" || relativePath.startsWith("~/") ? `./${relativePath}` : relativePath;
119
+ }
123
120
  export function assertRelativePath(relativePath) {
124
121
  const raw = relativePath.trim();
125
122
  if (!raw || raw !== relativePath) {
@@ -2,6 +2,7 @@ import fsSync, {} from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { FsSafeError } from "./errors.js";
5
+ import { literalStoreRootPath } from "./file-store-boundary.js";
5
6
  import { isPathInside } from "./path.js";
6
7
  import { realpathSync } from "./realpath.js";
7
8
  import { recursiveMkdirPath } from "./recursive-mkdir-path.js";
@@ -74,13 +75,13 @@ export async function pruneExpiredStoreEntries(params) {
74
75
  await assertRootGuard();
75
76
  // Keep empty-dir pruning on the same root-bounded remove path as files;
76
77
  // the Root fallback handles empty directories without recursive delete.
77
- await scopedRoot.remove(relativePath, REMOVE_EMPTY_DIRECTORY_OPTIONS).catch(() => undefined);
78
+ await scopedRoot.remove(literalStoreRootPath(relativePath), REMOVE_EMPTY_DIRECTORY_OPTIONS).catch(() => undefined);
78
79
  }
79
80
  continue;
80
81
  }
81
82
  if (stat.isFile() && now - stat.mtimeMs > params.options.ttlMs) {
82
83
  await assertRootGuard();
83
- await scopedRoot.remove(relativePath, {
84
+ await scopedRoot.remove(literalStoreRootPath(relativePath), {
84
85
  assertBeforeMutation: () => {
85
86
  // Removal preparation can outlive the expiry observation above.
86
87
  const current = fsSync.lstatSync(fullPath);
@@ -3,7 +3,7 @@ import { normalizeMaxBytes } from "./byte-budget.js";
3
3
  import { readFileDescriptorBoundedSync } from "./bounded-read.js";
4
4
  import { FsSafeError } from "./errors.js";
5
5
  import { pruneExpiredStoreEntries } from "./file-store-prune.js";
6
- import { assertFileStoreMaxBytes, assertRelativePath, ensureParentInRoot, openPrivateStoreLockRoot, openWritableStoreRoot, readFileStoreCopySource, resolveStorePath, writeStreamToTempSource, } from "./file-store-boundary.js";
6
+ import { assertFileStoreMaxBytes, assertRelativePath, ensureParentInRoot, literalStoreRootPath, openPrivateStoreLockRoot, openWritableStoreRoot, readFileStoreCopySource, resolveStorePath, writeStreamToTempSource, } from "./file-store-boundary.js";
7
7
  import { writeFileSyncAtomic } from "./file-store-sync-write.js";
8
8
  import { createJsonStore } from "./json-document-store.js";
9
9
  import { stringifyJsonDocument } from "./json-stringify.js";
@@ -61,7 +61,7 @@ function handleSyncStoreReadOpenFailure(opened) {
61
61
  });
62
62
  }
63
63
  async function copyIntoRoot(params) {
64
- const relativePath = assertRelativePath(params.relativePath);
64
+ const relativePath = params.relativePath;
65
65
  const destination = resolveStorePath(params.rootDir, relativePath);
66
66
  assertNoWindowsPathAlias(params.sourcePath, "filesystem", "source path uses a Windows filesystem namespace alias");
67
67
  const sourceStat = syncFs.lstatSync(params.sourcePath);
@@ -75,7 +75,7 @@ async function copyIntoRoot(params) {
75
75
  maxBytes: params.maxBytes,
76
76
  });
77
77
  await ensureParentInRoot(scopedRoot, relativePath, params.dirMode);
78
- await scopedRoot.copyIn(relativePath, params.sourcePath, {
78
+ await scopedRoot.copyIn(literalStoreRootPath(relativePath), params.sourcePath, {
79
79
  durable: params.durable,
80
80
  maxBytes: params.maxBytes,
81
81
  mkdir: false,
@@ -97,8 +97,7 @@ export function fileStore(options) {
97
97
  return await root(rootDir, { hardlinks: "reject", maxBytes });
98
98
  }
99
99
  async function write(relativePath, data, writeOptions) {
100
- const safeRelativePath = assertRelativePath(relativePath);
101
- const destination = resolveStorePath(rootDir, safeRelativePath);
100
+ const destination = resolveStorePath(rootDir, relativePath);
102
101
  const content = Buffer.isBuffer(data) ? data : Buffer.from(data);
103
102
  const writeMaxBytes = normalizeMaxBytes(writeOptions?.maxBytes, { defaultValue: maxBytes });
104
103
  assertFileStoreMaxBytes(content.byteLength, writeMaxBytes);
@@ -121,8 +120,8 @@ export function fileStore(options) {
121
120
  dirMode: writeDirMode,
122
121
  maxBytes: writeMaxBytes,
123
122
  });
124
- await ensureParentInRoot(scopedRoot, safeRelativePath, writeDirMode);
125
- await scopedRoot.write(safeRelativePath, content, {
123
+ await ensureParentInRoot(scopedRoot, relativePath, writeDirMode);
124
+ await scopedRoot.write(literalStoreRootPath(relativePath), content, {
126
125
  mkdir: false,
127
126
  mode: writeMode,
128
127
  durable: writeDurable,
@@ -135,8 +134,7 @@ export function fileStore(options) {
135
134
  root: openRoot,
136
135
  write,
137
136
  writeStream: async (relativePath, stream, writeOptions) => {
138
- const safeRelativePath = assertRelativePath(relativePath);
139
- const destination = resolveStorePath(rootDir, safeRelativePath);
137
+ const destination = resolveStorePath(rootDir, relativePath);
140
138
  const configuredLimit = normalizeMaxBytes(writeOptions?.maxBytes, { defaultValue: maxBytes });
141
139
  const limit = configuredLimit ?? (privateMode ? DEFAULT_ROOT_MAX_BYTES : undefined);
142
140
  if (privateMode) {
@@ -172,7 +170,7 @@ export function fileStore(options) {
172
170
  try {
173
171
  await copyIntoRoot({
174
172
  rootDir,
175
- relativePath: safeRelativePath,
173
+ relativePath,
176
174
  sourcePath: staged.path,
177
175
  durable: writeDurable,
178
176
  maxBytes: limit,
@@ -214,17 +212,17 @@ export function fileStore(options) {
214
212
  tempPrefix: writeOptions?.tempPrefix,
215
213
  });
216
214
  },
217
- open: async (relativePath, readOptions) => await (await openRoot()).open(assertRelativePath(relativePath), readOptions),
218
- read: async (relativePath, readOptions) => await (await openRoot()).read(assertRelativePath(relativePath), readOptions),
219
- readBytes: async (relativePath, readOptions) => await (await openRoot()).readBytes(assertRelativePath(relativePath), readOptions),
215
+ open: async (relativePath, readOptions) => await (await openRoot()).open(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
216
+ read: async (relativePath, readOptions) => await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
217
+ readBytes: async (relativePath, readOptions) => await (await openRoot()).readBytes(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
220
218
  readText: async (relativePath, readOptions) => {
221
219
  const { encoding = "utf8", ...options } = readOptions ?? {};
222
- return (await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
220
+ return (await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), options)).buffer
223
221
  .toString(encoding);
224
222
  },
225
223
  readTextIfExists: async (relativePath, readOptions) => {
226
224
  try {
227
- return await (await openRoot()).readText(assertRelativePath(relativePath), readOptions);
225
+ return await (await openRoot()).readText(literalStoreRootPath(assertRelativePath(relativePath)), readOptions);
228
226
  }
229
227
  catch (error) {
230
228
  if (isNotFound(error)) {
@@ -235,12 +233,12 @@ export function fileStore(options) {
235
233
  },
236
234
  readJson: async (relativePath, readOptions) => {
237
235
  const { encoding = "utf8", ...options } = readOptions ?? {};
238
- return JSON.parse((await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
236
+ return JSON.parse((await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), options)).buffer
239
237
  .toString(encoding));
240
238
  },
241
239
  readJsonIfExists: async (relativePath, readOptions) => {
242
240
  try {
243
- return await (await openRoot()).readJson(assertRelativePath(relativePath), readOptions);
241
+ return await (await openRoot()).readJson(literalStoreRootPath(assertRelativePath(relativePath)), readOptions);
244
242
  }
245
243
  catch (error) {
246
244
  if (isNotFound(error)) {
@@ -250,9 +248,9 @@ export function fileStore(options) {
250
248
  }
251
249
  },
252
250
  remove: async (relativePath) => {
253
- await (await openRoot()).remove(assertRelativePath(relativePath));
251
+ await (await openRoot()).remove(literalStoreRootPath(assertRelativePath(relativePath)));
254
252
  },
255
- exists: async (relativePath) => await (await openRoot()).exists(assertRelativePath(relativePath)),
253
+ exists: async (relativePath) => await (await openRoot()).exists(literalStoreRootPath(assertRelativePath(relativePath))),
256
254
  writeText: async (relativePath, data, writeOptions) => await write(relativePath, data, writeOptions),
257
255
  writeJson: async (relativePath, data, writeOptions) => {
258
256
  const trailingNewline = writeOptions?.trailingNewline;
@@ -269,7 +267,7 @@ export function fileStore(options) {
269
267
  } : {}),
270
268
  readIfExists: async () => {
271
269
  try {
272
- return await (await openRoot()).readJson(assertRelativePath(relativePath));
270
+ return await (await openRoot()).readJson(literalStoreRootPath(relativePath));
273
271
  }
274
272
  catch (error) {
275
273
  if (isNotFound(error)) {
@@ -278,7 +276,7 @@ export function fileStore(options) {
278
276
  throw error;
279
277
  }
280
278
  },
281
- readRequired: async () => await (await openRoot()).readJson(assertRelativePath(relativePath)),
279
+ readRequired: async () => await (await openRoot()).readJson(literalStoreRootPath(relativePath)),
282
280
  write: async (value, options) => {
283
281
  const json = stringifyJsonDocument(value, null, 2);
284
282
  await write(relativePath, options?.trailingNewline === false ? json : `${json}\n`, { durable: options?.durable });
@@ -36,15 +36,6 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
36
36
  " is_linux = sys.platform.startswith('linux')",
37
37
  " if is_linux:",
38
38
  " rename_fn = getattr(libc, 'renameat2', None)",
39
- " if rename_fn is None:",
40
- " os.link(",
41
- " src_basename,",
42
- " dst_basename,",
43
- " src_dir_fd=src_parent_fd,",
44
- " dst_dir_fd=dst_parent_fd,",
45
- " follow_symlinks=False,",
46
- " )",
47
- " return",
48
39
  " flags = 1 # RENAME_NOREPLACE",
49
40
  " elif sys.platform == 'darwin':",
50
41
  " rename_fn = getattr(libc, 'renameatx_np', None)",
@@ -52,32 +43,33 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
52
43
  " else:",
53
44
  " rename_fn = None",
54
45
  " flags = 0",
55
- " if rename_fn is None:",
56
- " raise OSError(errno.ENOSYS, 'atomic no-replace rename is unavailable')",
57
- " rename_fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]",
58
- " rename_fn.restype = ctypes.c_int",
59
- " result = rename_fn(",
60
- " src_parent_fd,",
61
- " os.fsencode(src_basename),",
62
- " dst_parent_fd,",
63
- " os.fsencode(dst_basename),",
64
- " flags,",
65
- " )",
66
- " if result != 0:",
46
+ " if rename_fn is not None:",
47
+ " rename_fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]",
48
+ " rename_fn.restype = ctypes.c_int",
49
+ " result = rename_fn(",
50
+ " src_parent_fd,",
51
+ " os.fsencode(src_basename),",
52
+ " dst_parent_fd,",
53
+ " os.fsencode(dst_basename),",
54
+ " flags,",
55
+ " )",
56
+ " if result == 0:",
57
+ " return",
67
58
  " error_code = ctypes.get_errno()",
68
59
  " unsupported_codes = {errno.ENOSYS, errno.EINVAL, errno.ENOTSUP}",
69
60
  " if hasattr(errno, 'EOPNOTSUPP'):",
70
61
  " unsupported_codes.add(errno.EOPNOTSUPP)",
71
- " if is_linux and error_code in unsupported_codes:",
72
- " os.link(",
73
- " src_basename,",
74
- " dst_basename,",
75
- " src_dir_fd=src_parent_fd,",
76
- " dst_dir_fd=dst_parent_fd,",
77
- " follow_symlinks=False,",
78
- " )",
79
- " return",
80
- " raise OSError(error_code, os.strerror(error_code), dst_basename)",
62
+ " if not is_linux or error_code not in unsupported_codes:",
63
+ " raise OSError(error_code, os.strerror(error_code), dst_basename)",
64
+ " elif not is_linux:",
65
+ " raise OSError(errno.ENOSYS, 'atomic no-replace rename is unavailable')",
66
+ " os.link(",
67
+ " src_basename,",
68
+ " dst_basename,",
69
+ " src_dir_fd=src_parent_fd,",
70
+ " dst_dir_fd=dst_parent_fd,",
71
+ " follow_symlinks=False,",
72
+ " )",
81
73
  ].join("\n");
82
74
  export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
83
75
  "def create_exclusive(parent_fd, basename, stdin_buffer):",
package/dist/guest.js CHANGED
@@ -270,13 +270,15 @@ export const GUEST_FILESYSTEM_PYTHON = [
270
270
  "def copy_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
271
271
  " src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
272
272
  " if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
273
- " os.mkdir(dst_basename, stat.S_IMODE(src_stat.st_mode) or 0o755, dir_fd=dst_parent_fd)",
274
273
  " copied_children = []",
275
274
  " src_dir_fd = None",
276
275
  " dst_dir_fd = None",
276
+ " destination_created = False",
277
277
  " try:",
278
278
  " src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
279
279
  " src_stat = os.fstat(src_dir_fd)",
280
+ " os.mkdir(dst_basename, stat.S_IMODE(src_stat.st_mode), dir_fd=dst_parent_fd)",
281
+ " destination_created = True",
280
282
  " dst_dir_fd = open_dir(dst_basename, dir_fd=dst_parent_fd)",
281
283
  " for child in os.listdir(src_dir_fd):",
282
284
  " copied_children.append((child, copy_entry(src_dir_fd, child, dst_dir_fd, child)))",
@@ -284,10 +286,11 @@ export const GUEST_FILESYSTEM_PYTHON = [
284
286
  " if dst_dir_fd is not None:",
285
287
  " os.close(dst_dir_fd)",
286
288
  " dst_dir_fd = None",
287
- " try:",
288
- " remove_tree(dst_parent_fd, dst_basename)",
289
- " except FileNotFoundError:",
290
- " pass",
289
+ " if destination_created:",
290
+ " try:",
291
+ " remove_tree(dst_parent_fd, dst_basename)",
292
+ " except FileNotFoundError:",
293
+ " pass",
291
294
  " raise",
292
295
  " finally:",
293
296
  " if src_dir_fd is not None:",
@@ -328,30 +331,36 @@ export const GUEST_FILESYSTEM_PYTHON = [
328
331
  " raise",
329
332
  " src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
330
333
  " if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
331
- " temp_dir_name = create_temp_dir(dst_parent_fd, stat.S_IMODE(src_stat.st_mode) or 0o755)",
334
+ " temp_dir_name = None",
332
335
  " copied_children = []",
333
336
  " temp_dir_fd = None",
334
337
  " src_dir_fd = None",
335
338
  " try:",
336
- " temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
337
339
  " src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
338
340
  " src_stat = os.fstat(src_dir_fd)",
341
+ " source_mode = stat.S_IMODE(src_stat.st_mode)",
342
+ " temp_dir_name = create_temp_dir(dst_parent_fd, source_mode or 0o700)",
343
+ " temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
339
344
  " for child in os.listdir(src_dir_fd):",
340
345
  " copied_children.append((child, copy_entry(src_dir_fd, child, temp_dir_fd, child)))",
341
346
  " os.close(src_dir_fd)",
342
347
  " src_dir_fd = None",
348
+ " os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
349
+ " temp_dir_name = None",
350
+ " if source_mode == 0:",
351
+ " os.fchmod(temp_dir_fd, 0)",
343
352
  " os.close(temp_dir_fd)",
344
353
  " temp_dir_fd = None",
345
- " os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
346
354
  " except Exception:",
347
355
  " if src_dir_fd is not None:",
348
356
  " os.close(src_dir_fd)",
349
357
  " if temp_dir_fd is not None:",
350
358
  " os.close(temp_dir_fd)",
351
- " try:",
352
- " remove_tree(dst_parent_fd, temp_dir_name)",
353
- " except FileNotFoundError:",
354
- " pass",
359
+ " if temp_dir_name is not None:",
360
+ " try:",
361
+ " remove_tree(dst_parent_fd, temp_dir_name)",
362
+ " except FileNotFoundError:",
363
+ " pass",
355
364
  " raise",
356
365
  " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
357
366
  " os.fsync(dst_parent_fd)",
@@ -145,8 +145,7 @@ export async function loadPendingJsonDurableQueueEntries(options) {
145
145
  await unlinkStaleTmpBestEffort(path.join(queueDir, file), now, options.cleanupTmpMaxAgeMs);
146
146
  }
147
147
  }
148
- const ids = [];
149
- const seenIds = new Set();
148
+ const ids = new Set();
150
149
  for (const file of files) {
151
150
  const suffix = file.endsWith(".processing")
152
151
  ? ".processing"
@@ -162,10 +161,7 @@ export async function loadPendingJsonDurableQueueEntries(options) {
162
161
  catch {
163
162
  continue;
164
163
  }
165
- if (seenIds.has(id))
166
- continue;
167
- seenIds.add(id);
168
- ids.push(id);
164
+ ids.add(id);
169
165
  }
170
166
  const entries = [];
171
167
  for (const id of ids) {
@@ -1,16 +1,13 @@
1
1
  import type { BigIntStats, Stats } from "node:fs";
2
2
  import type { FileHandle } from "node:fs/promises";
3
- import type { HardlinkPolicy } from "./root-options.js";
4
- import type { SymlinkPolicy } from "./root-symlink-policy.js";
3
+ import type { RootReadOptions } from "./root-options.js";
5
4
  type OwnedLocalFile = {
6
5
  handle: FileHandle;
7
6
  stat: Stats;
8
7
  identity: BigIntStats;
9
8
  preOpenStat: BigIntStats | undefined;
10
9
  };
11
- export declare function openLocalFileDescriptor(filePath: string, options?: {
12
- hardlinks?: HardlinkPolicy;
13
- symlinks?: SymlinkPolicy;
10
+ export declare function openLocalFileDescriptor(filePath: string, options?: Pick<RootReadOptions, "hardlinks" | "symlinks"> & {
14
11
  readWrite?: true;
15
12
  }): Promise<OwnedLocalFile>;
16
13
  export {};
@@ -1,4 +1,4 @@
1
- import { type HardlinkPolicy, type ReadResult, type SymlinkPolicy } from "./root.js";
1
+ import { type ReadResult, type RootReadOptions } from "./root.js";
2
2
  export type LocalRootsPathResult = {
3
3
  path: string;
4
4
  root: string;
@@ -15,11 +15,6 @@ export type ResolveLocalPathFromRootsSyncOptions = LocalRootsInputOptions & {
15
15
  allowMissing?: boolean;
16
16
  requireFile?: boolean;
17
17
  };
18
- export type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
19
- hardlinks?: HardlinkPolicy;
20
- maxBytes?: number;
21
- nonBlockingRead?: boolean;
22
- symlinks?: SymlinkPolicy;
23
- };
18
+ export type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & RootReadOptions;
24
19
  export declare function resolveLocalPathFromRootsSync(options: ResolveLocalPathFromRootsSyncOptions): LocalRootsPathResult | null;
25
20
  export declare function readLocalFileFromRoots(options: ReadLocalFileFromRootsOptions): Promise<LocalRootsReadResult | null>;
@@ -1,6 +1,7 @@
1
1
  import type { TarMeterLimits } from "./archive-limits.js";
2
2
  import type { ArchiveMemberKind } from "./archive-plan.js";
3
3
  import type { CopyCloneMode } from "./copy-policy.js";
4
+ import type { WindowsAceFlags } from "./owner-dacl.js";
4
5
  export interface NativeFileHash {
5
6
  bytes: number;
6
7
  digest: string;
@@ -58,16 +59,7 @@ export interface NativeWindowsAccessControlEntry {
58
59
  sid: string;
59
60
  mask: number;
60
61
  aceType: string;
61
- flags: {
62
- raw: number;
63
- objectInherit: boolean;
64
- containerInherit: boolean;
65
- noPropagateInherit: boolean;
66
- inheritOnly: boolean;
67
- inherited: boolean;
68
- successfulAccess: boolean;
69
- failedAccess: boolean;
70
- };
62
+ flags: WindowsAceFlags;
71
63
  }
72
64
  export interface NativeWindowsSecurityFacts {
73
65
  ownerSid: string;
@@ -96,6 +88,12 @@ export interface NativeWindowsDirectoryReceipt {
96
88
  export interface NativeDarwinAclFacts {
97
89
  state: "absent" | "empty" | "present";
98
90
  }
91
+ type NativeTwoPathArgs = [
92
+ sourceRootFd: number,
93
+ sourceRelPath: string,
94
+ targetRootFd: number,
95
+ targetRelPath: string
96
+ ];
99
97
  export interface NativeBinding {
100
98
  /** Internal: consumes only a descriptor returned by this binding. */
101
99
  closeOwnedFd(fd: number): void;
@@ -141,7 +139,7 @@ export interface NativeBinding {
141
139
  extractArchiveNative(path: string, kind: string, rootFd: number, plan: NativeArchivePlanEntry[], limits: TarMeterLimits, signal: AbortSignal): Promise<void>;
142
140
  fstatIdentity(fd: number): NativeFileIdentity;
143
141
  inspectArchiveNative(path: string, kind: string, limits: TarMeterLimits, signal: AbortSignal): Promise<NativeArchiveEntry[]>;
144
- linkBeneath(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string): void;
142
+ linkBeneath(...args: NativeTwoPathArgs): void;
145
143
  /** Direct-child mkdir; true is receipt provenance only, never cleanup ownership. */
146
144
  mkdirChildBeneath?(parentFd: number, basename: string, mode: number): boolean;
147
145
  mkdirBeneath(rootFd: number, relPath: string, mode: number): void;
@@ -153,10 +151,11 @@ export interface NativeBinding {
153
151
  ownedTreeRemovalAvailable?(parentFd: number): boolean;
154
152
  removeOwnedTree?(parentFd: number, basename: string, directoryFd: number): Promise<NativeOwnedTreeRemovalResult>;
155
153
  removeOwnedTreeSync?(parentFd: number, basename: string, directoryFd: number): NativeOwnedTreeRemovalResult;
156
- renameNoReplace(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string): void;
154
+ renameNoReplace(...args: NativeTwoPathArgs): void;
157
155
  /** Identity-fenced retained-directory rename capability. */
158
156
  renameNoReplaceWithIdentity?(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string, expectedSourceDev: bigint, expectedSourceIno: bigint): void;
159
- renameReplace(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string): void;
157
+ renameReplace(...args: NativeTwoPathArgs): void;
160
158
  sha256File(fd: number, maxBytes?: number, signal?: AbortSignal): Promise<NativeFileHash>;
161
159
  }
162
160
  export declare function captureNativeFdClose(binding: NativeBinding): (fd: number) => void;
161
+ export {};
@@ -1,14 +1,9 @@
1
- import type { DenyMutationPolicy } from "./deny-mutations.js";
1
+ import type { PinnedMutationPolicySnapshot } from "./pinned-mutation-admission.js";
2
2
  import type { RootBoundaryIdentity } from "./root-boundary.js";
3
- import type { MutationSymlinkPolicy } from "./root-symlink-policy.js";
4
3
  export type ExactRootIdentity = Readonly<{
5
4
  dev: bigint;
6
5
  ino: bigint;
7
6
  }>;
8
- type SharedMutationPolicy = Readonly<{
9
- denyMutations?: DenyMutationPolicy;
10
- mutationSymlinks?: MutationSymlinkPolicy;
11
- }>;
12
7
  export declare function ordinaryWindowsSegments(relativePath: string): boolean;
13
8
  export declare function ordinarySharedAbsoluteInsideRoot(rootReal: string, candidatePath: string, rootIdentity: ExactRootIdentity): boolean;
14
9
  export declare function simpleSharedRoute(params: {
@@ -16,9 +11,8 @@ export declare function simpleSharedRoute(params: {
16
11
  rootIdentity?: RootBoundaryIdentity;
17
12
  originalPath?: string;
18
13
  selectedTarget: string;
19
- policy: SharedMutationPolicy;
14
+ policy: PinnedMutationPolicySnapshot;
20
15
  }): {
21
16
  route: string;
22
17
  rootIdentity: ExactRootIdentity;
23
18
  } | undefined;
24
- export {};
@@ -107,7 +107,7 @@ export function rootRelativeReadPath(root, filePath) {
107
107
  resolveCandidateRoot: base === root.rootDir && root.rootDir !== root.rootReal,
108
108
  });
109
109
  if (admitted)
110
- return admitted.relativePath;
110
+ return `.${path.sep}${admitted.relativePath}`;
111
111
  continue;
112
112
  }
113
113
  const prefix = ensureTrailingSep(base);
@@ -116,7 +116,8 @@ export function rootRelativeReadPath(root, filePath) {
116
116
  let start = prefix.length;
117
117
  while (raw[start] === path.sep)
118
118
  start += 1;
119
- return raw.slice(start);
119
+ // An admitted absolute tail stays literal, including a leading home marker.
120
+ return `.${path.sep}${raw.slice(start)}`;
120
121
  }
121
122
  }
122
123
  return raw;
@@ -1,13 +1,8 @@
1
1
  import { type BigIntStats, type Stats } from "node:fs";
2
- import { type DenyMutationPolicy } from "./deny-mutations.js";
2
+ import type { RootMoveOptions } from "./root-options.js";
3
3
  import { type RootContext } from "./root-context.js";
4
- import { type MutationSymlinkPolicy } from "./root-symlink-policy.js";
5
4
  export declare function admitMoveSourceStat<T extends Stats | BigIntStats>(stat: T, overwrite?: boolean): T;
6
- export declare function movePathNoReplaceNative(root: RootContext, params: {
7
- assertBeforeMutation?: () => void;
8
- denyMutations?: DenyMutationPolicy;
9
- mutationSymlinks?: MutationSymlinkPolicy;
10
- }, paths: {
5
+ export declare function movePathNoReplaceNative(root: RootContext, params: Pick<RootMoveOptions, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">, paths: {
11
6
  sourcePath: string;
12
7
  sourceParentPath: string;
13
8
  targetPath: string;
@@ -1,3 +1,4 @@
1
+ import { type ResolvePathWithinRootParams } from "./path-scope-lexical.js";
1
2
  import { type DirectoryResult } from "./root-directory.js";
2
3
  export { resolvePathWithinRoot } from "./root-paths-lexical.js";
3
4
  export { ensureDirectoryWithinRoot } from "./root-directory.js";
@@ -44,12 +45,7 @@ export type PathScope = {
44
45
  mode?: number;
45
46
  }): Promise<DirectoryResult>;
46
47
  };
47
- export declare function resolveWritablePathWithinRoot(params: {
48
- rootDir: string;
49
- requestedPath: string;
50
- scopeLabel: string;
51
- defaultFileName?: string;
52
- }): Promise<{
48
+ export declare function resolveWritablePathWithinRoot(params: ResolvePathWithinRootParams): Promise<{
53
49
  ok: true;
54
50
  path: string;
55
51
  } | {
@@ -1,9 +1,7 @@
1
1
  import type { BigIntStats } from "node:fs";
2
2
  type ExactDirectoryIdentity = Readonly<Pick<BigIntStats, "dev" | "ino">>;
3
- export type RemovalDirectoryAssertion = Readonly<{
3
+ export type RemovalDirectoryAssertion = ExactDirectoryIdentity & Readonly<{
4
4
  path: string;
5
- dev: bigint;
6
- ino: bigint;
7
5
  numericDev?: number;
8
6
  numericIno?: number;
9
7
  platform: NodeJS.Platform;
package/dist/root-walk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { FsSafeError } from "./errors.js";
3
+ import { expandRelativePathWithHome } from "./root-context.js";
3
4
  import { resolveRootPath, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
4
5
  import { createSuppressedError } from "./suppressed-error.js";
5
6
  function validateBudget(name, value) {
@@ -65,9 +66,10 @@ export async function* walkRoot(root, relativePath, options) {
65
66
  options.signal?.throwIfAborted();
66
67
  let listing;
67
68
  try {
69
+ const expandedDirectory = depth === 0 ? await expandRelativePathWithHome(directory) : directory;
68
70
  const skipChildSymlinks = depth > 0 && options.symlinkPolicy === "skip";
69
71
  const resolvedDirectory = await resolveRootPath({
70
- absolutePath: path.resolve(root.rootReal, directory),
72
+ absolutePath: path.resolve(root.rootReal, expandedDirectory),
71
73
  rootPath: root.rootReal,
72
74
  rootCanonicalPath: root.rootReal,
73
75
  boundaryLabel: "root walk",
@@ -87,7 +89,10 @@ export async function* walkRoot(root, relativePath, options) {
87
89
  .relative(root.rootReal, resolvedDirectory.canonicalPath)
88
90
  .split(path.sep)
89
91
  .join(path.posix.sep);
90
- listing = await root.list(listingDirectory, {
92
+ if (depth === 0 && expandedDirectory !== directory)
93
+ directory = listingDirectory;
94
+ // Resolved filesystem names are literal, not caller home-directory shorthand.
95
+ listing = await root.list(`./${listingDirectory}`, {
91
96
  order: options.order ?? "sorted",
92
97
  signal: options.signal,
93
98
  snapshot: maxEntries === Number.POSITIVE_INFINITY,
@@ -139,7 +144,7 @@ export async function* walkRoot(root, relativePath, options) {
139
144
  if (!resolved.exists) {
140
145
  continue;
141
146
  }
142
- const target = await root.stat(path.relative(root.rootReal, resolved.canonicalPath));
147
+ const target = await root.stat(`./${path.relative(root.rootReal, resolved.canonicalPath)}`);
143
148
  kind = target.isDirectory ? "directory" : target.isFile ? "file" : "other";
144
149
  size = target.size;
145
150
  }
@@ -14,7 +14,7 @@ import { mutationSymlinkResolution, } from "./root-symlink-policy.js";
14
14
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
15
15
  import { realpathSync } from "./realpath.js";
16
16
  import { getFsSafeTestHooks } from "./test-hooks.js";
17
- import { assertPreparedRootWriteParentCurrent, } from "./root-write-complete-parent.js";
17
+ import { assertPreparedRootWriteParentCurrent, writeSelectionChanged, } from "./root-write-complete-parent.js";
18
18
  export function createRootWriteSelectionForFd(selection, fd) {
19
19
  const stat = inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }));
20
20
  return Object.freeze({
@@ -22,9 +22,6 @@ export function createRootWriteSelectionForFd(selection, fd) {
22
22
  identity: Object.freeze({ dev: stat.dev, ino: stat.ino }),
23
23
  });
24
24
  }
25
- function writeSelectionChanged(cause) {
26
- return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
27
- }
28
25
  function inspectRegularSelectionPath(pathname, expected, follow) {
29
26
  let observed;
30
27
  try {
@@ -1,5 +1,6 @@
1
1
  import type { BigIntStats } from "node:fs";
2
2
  import { type AsyncDirectoryGuard } from "./directory-guard.js";
3
+ import { FsSafeError } from "./errors.js";
3
4
  import type { RootContext } from "./root-context.js";
4
5
  import type { GuardedRootWriteTarget } from "./root-write-admission.js";
5
6
  type PreparedWriteTargetObservation = Readonly<{
@@ -36,6 +37,7 @@ export type SharedRootWriteTarget = Readonly<{
36
37
  mutationAdmission: GuardedRootWriteTarget["mutationAdmission"];
37
38
  preparedParent?: PreparedRootWriteParent;
38
39
  }>;
40
+ export declare function writeSelectionChanged(cause?: unknown): FsSafeError;
39
41
  export declare function assertPreparedRootWriteParentCurrent(prepared: PreparedRootWriteParent, verifyTarget?: boolean): void;
40
42
  export declare function prepareSharedRootWriteTarget(root: RootContext, params: SharedRootWriteTargetParams): Promise<SharedRootWriteTarget>;
41
43
  export {};
@@ -12,7 +12,7 @@ import { errorCauseOptions } from "./root-errors.js";
12
12
  import { canReuseParentWithMutationAssertion } from "./root-write-lock-binding.js";
13
13
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
14
14
  import { getFsSafeTestHooks } from "./test-hooks.js";
15
- function writeSelectionChanged(cause) {
15
+ export function writeSelectionChanged(cause) {
16
16
  return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
17
17
  }
18
18
  function ordinarySharedWriteRoute(root, relativePath, operationTargetPath) {
@@ -13,7 +13,11 @@ type SecretFileWriteParams = {
13
13
  dirMode?: number;
14
14
  durable?: boolean;
15
15
  };
16
- export declare function prepareSecretFileWrite(params: Omit<SecretFileWriteParams, "content">): Promise<{
16
+ type SecretFileCreateParams = Omit<SecretFileWriteParams, "durable"> & {
17
+ /** "file" requires file synchronization; directory synchronization remains best effort. */
18
+ durable?: boolean | "file";
19
+ };
20
+ export declare function prepareSecretFileWrite(params: Pick<SecretFileWriteParams, "rootDir" | "filePath" | "mode" | "dirMode">): Promise<{
17
21
  mode: number;
18
22
  rootGuard: AsyncDirectoryGuard<BigIntStats>;
19
23
  parentGuard: AsyncDirectoryGuard<BigIntStats>;
@@ -21,5 +25,5 @@ export declare function prepareSecretFileWrite(params: Omit<SecretFileWriteParam
21
25
  finalFilePath: string;
22
26
  }>;
23
27
  export declare function writeSecretFileAtomic(params: SecretFileWriteParams): Promise<void>;
24
- export declare function createSecretFileAtomic(params: SecretFileWriteParams): Promise<void>;
28
+ export declare function createSecretFileAtomic(params: SecretFileCreateParams): Promise<void>;
25
29
  export {};
@@ -285,6 +285,7 @@ async function materializeSecretFileAtomic(params, createOnly) {
285
285
  mode,
286
286
  verifyPosixMode: true,
287
287
  sync: params.durable !== false,
288
+ strictFileSync: createOnly && params.durable === "file",
288
289
  overwrite: !createOnly,
289
290
  input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
290
291
  rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
@@ -1,15 +1,11 @@
1
- import { FsSafeError } from "./errors.js";
2
1
  import { fileIdentityMismatchError } from "./strict-file-identity.js";
3
2
  import { getNativeBinding } from "./native.js";
4
3
  import { getFsSafeNativeConfig } from "./native-config.js";
5
4
  import { warnNativeFallback } from "./native-fallback-warning.js";
6
5
  import { inspectWindowsDescriptorCommand } from "./windows-security-command.js";
7
- import { validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
6
+ import { unverified as permissionUnverified, validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
8
7
  const IDENTITY_RE = /^([0-9a-f]{8}):([0-9a-f]{16})$/;
9
8
  const TRUSTED_OWNER_CLASSES = new Set(["current-user", "system", "administrators"]);
10
- function permissionUnverified(message, cause) {
11
- throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
12
- }
13
9
  function isRecord(value) {
14
10
  return typeof value === "object" && value !== null;
15
11
  }
@@ -1,7 +1,7 @@
1
1
  import type { Root } from "./root-impl.js";
2
2
  import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
3
3
  import { readSidecarLockRawSnapshot, type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
4
- type SidecarAdmissionRunner = {
4
+ export type SidecarAdmissionRunner = {
5
5
  hasToken(): boolean;
6
6
  run<T>(callback: () => T): T;
7
7
  };
@@ -39,4 +39,3 @@ export declare function observeHeldSidecarParser(params: {
39
39
  parserAccessor(): ((raw: string) => unknown) | undefined;
40
40
  isTransientDenial(error: unknown): boolean;
41
41
  }): Promise<HeldSidecarParserObservation>;
42
- export {};
@@ -1,5 +1,4 @@
1
- import type { Root } from "./root-impl.js";
2
- import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
1
+ import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
3
2
  import type { SidecarLockHandle } from "./sidecar-lock-types.js";
4
3
  export declare function stopSidecarLockMonitoring(held: {
5
4
  compromiseTimer?: NodeJS.Timeout;
@@ -14,12 +13,7 @@ export declare function createSidecarLockHandle(params: {
14
13
  }): SidecarLockHandle;
15
14
  export declare function createHeldSidecarLockHandle(params: {
16
15
  normalizedTargetPath: string;
17
- held: {
18
- lockPath: string;
19
- snapshot: SidecarLockSnapshot;
20
- lockRoot?: Root;
21
- parsePayload?: (raw: string) => unknown;
22
- };
16
+ held: Pick<HeldSidecarLock, "lockPath" | "snapshot" | "lockRoot" | "parsePayload">;
23
17
  release: (options?: {
24
18
  retry?: boolean;
25
19
  }) => Promise<unknown>;
@@ -1,4 +1,4 @@
1
- import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
1
+ import type { SidecarLockReclaimParams, SidecarLockRetryOptions } from "./sidecar-lock-types.js";
2
2
  export declare function validateSidecarLockRetryOptions(retry: SidecarLockRetryOptions): void;
3
3
  export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefined): void;
4
4
  export declare function validateSidecarLockStaleMs(staleMs: number | undefined): void;
@@ -11,9 +11,4 @@ export declare function sidecarLockRetryDelay(retry: SidecarLockRetryOptions, ti
11
11
  export declare const maxTransientLockDenials = 8;
12
12
  export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
13
13
  export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
14
- export declare function defaultSidecarLockShouldReclaim(params: {
15
- lockPath: string;
16
- payload: unknown;
17
- staleMs: number;
18
- nowMs: number;
19
- }): Promise<boolean>;
14
+ export declare function defaultSidecarLockShouldReclaim(params: Pick<SidecarLockReclaimParams, "lockPath" | "payload" | "staleMs" | "nowMs">): Promise<boolean>;
@@ -1,11 +1,7 @@
1
1
  import { type SidecarReclaimGuard } from "./sidecar-lock-reclaim.js";
2
- import { type SidecarLockParserState } from "./sidecar-lock-admission-parser.js";
2
+ import { type SidecarLockParserState, type SidecarAdmissionRunner } from "./sidecar-lock-admission-parser.js";
3
3
  import type { Root } from "./root-impl.js";
4
4
  import type { SidecarLockAcquireOptions } from "./sidecar-lock-types.js";
5
- type SidecarAdmissionRunner = {
6
- hasToken(): boolean;
7
- run<T>(callback: () => T): T;
8
- };
9
5
  type StaleOptions = Pick<SidecarLockAcquireOptions<Record<string, unknown>>, "shouldReclaim" | "shouldRemoveStaleLock" | "staleRecovery">;
10
6
  export type SidecarLockStaleOptionsState = {
11
7
  shouldReclaimObserved?: boolean;
@@ -25,7 +25,7 @@ export type FsSafeTestHooks = {
25
25
  beforeTempWorkspaceNativeRemovalSync?: (quarantinePath: string) => void;
26
26
  beforeTrashMove?: (targetPath: string, destPath: string) => void;
27
27
  afterPublishTargetCreated?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
- beforePublishDirectorySync?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
+ beforePublishDirectorySync?: NonNullable<FsSafeTestHooks["afterPublishTargetCreated"]>;
29
29
  };
30
30
  export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
31
31
  export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, PermissionCommandError } from "./permission-exec.js";
5
5
  import { resolveWindowsSystemCommand } from "./windows-command.js";
6
- import { parseWindowsSecurityCommandFacts } from "./windows-security-facts.js";
6
+ import { parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
7
7
  const MAX_OUTPUT_BYTES = 1024 * 1024;
8
8
  const TERMINATION_GRACE_MS = 1_000;
9
9
  const FULL_IDENTITY = /^[0-9a-f]{16}:[0-9a-f]{32}$/;
@@ -50,9 +50,6 @@ function command(operation, params) {
50
50
  },
51
51
  };
52
52
  }
53
- function unverified(message, cause) {
54
- throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
55
- }
56
53
  function record(value) {
57
54
  return value !== null && typeof value === "object" && !Array.isArray(value);
58
55
  }
@@ -1,4 +1,5 @@
1
1
  import type { NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ export declare function unverified(message: string, cause?: unknown): never;
2
3
  /** Raw reporting retains unknown flag bits; secure admission validates them below. */
3
4
  export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
4
5
  /** Native and command observations share the same fail-closed admission policy. */
@@ -6,8 +6,8 @@ const WORLD_SIDS = new Set([
6
6
  "s-1-1-0", "s-1-5-11", "s-1-5-32-545", "s-1-5-7",
7
7
  "s-1-5-32-546", "s-1-5-4", "s-1-5-2",
8
8
  ]);
9
- function unverified(message) {
10
- throw new FsSafeError("permission-unverified", message);
9
+ export function unverified(message, cause) {
10
+ throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
11
11
  }
12
12
  function record(value) {
13
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
package/docs/archive.md CHANGED
@@ -238,6 +238,8 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
238
238
  `pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
239
239
  spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
240
240
  Unicode Path names use the same canonicalization.
241
+ A leading `~` is a literal archive entry name and is never expanded to the
242
+ user's home directory during extraction, publication, or durability checks.
241
243
  Callbacks follow physical archive order, including ZIP names that look like
242
244
  integer object keys. The public ZIP loader's `files` object retains ordinary
243
245
  JavaScript object enumeration and mutation behavior.
package/docs/copy.md CHANGED
@@ -74,6 +74,8 @@ Native Windows byte copies can store large zero-filled chunks as sparse ranges w
74
74
 
75
75
  The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
76
76
 
77
+ Failed ReFS clones attempt to remove their partial output through the retained directory handles. On Windows versions that reject the ignore-readonly deletion flag, clone rollback retries without that flag so ordinary output can be removed. It never clears readonly attributes: readonly output can remain, and the original clone error includes the cleanup failure. Other processes retaining output handles can delay deletion beyond settlement; a failed call does not guarantee an absent destination.
78
+
77
79
  XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
78
80
 
79
81
  `readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
@@ -101,6 +101,11 @@ such as `internal space/a b.txt` are accepted. On POSIX, colons elsewhere, such
101
101
  as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid;
102
102
  Windows rejects that spelling as stream syntax.
103
103
 
104
+ Keys such as `~` and `~/state.json` name literal entries inside the store; they
105
+ do not expand the user's home directory. Reads, writes, removal, and pruning
106
+ all use that literal identity. The `Root` returned by `root()` retains its own
107
+ home-expansion behavior.
108
+
104
109
  Validation retains each method's operation order. Async reads, `exists`, and
105
110
  `remove` open the root first: if the root is missing, strict methods report
106
111
  `not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
package/docs/guest.md CHANGED
@@ -119,7 +119,13 @@ publication failure preserves the existing destination and source link; ordinary
119
119
  failure cleanup removes the staging directory.
120
120
 
121
121
  Cross-device directory moves build a copy manifest and check it during source
122
- cleanup. Source changes can leave the published destination and some or all
122
+ cleanup. Directory creation keeps the source mode subject to the guest's umask;
123
+ mode `000` is not replaced with a default. A top-level mode-000 directory uses
124
+ owner-only staging until publication, then restores zero through its retained
125
+ descriptor. Reading a mode-000 source still requires sufficient OS privileges;
126
+ the guest does not change source permissions to gain access. A permission error
127
+ after publication preserves the source and published copy for reconciliation.
128
+ Source changes can leave the published destination and some or all
123
129
  of the source. Regular-file and symlink move fallbacks unlink the source
124
130
  pathname after publication; they do not perform the directory manifest's
125
131
  identity checks. Directory cleanup also has check-to-unlink race windows.
@@ -107,6 +107,12 @@ normalization, and the decision to fall back.
107
107
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
108
108
  - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
109
109
 
110
+ Linux root lookups reject negative descriptor sentinels before borrowing a handle or resolving a relative path; they never substitute the process working directory for an admitted root. Public Root operations already supply retained, admitted handles.
111
+
112
+ On Linux and macOS, native asynchronous file-copy admission rejects negative source and parent descriptors before creating a stage, preserving source-before-parent error ordering. Callers must keep nonnegative source and parent descriptors open until the operation settles.
113
+
114
+ Low-level Unix query, hash, copy, clone, staging, and owned-tree cleanup calls also reject negative descriptors before using them, preserving each operation's path-validation, cancellation, and cleanup order. Descriptor-relative mutations never accept a working-directory sentinel as a retained capability. On modern macOS, beneath opens reject negative roots with `EBADF` before calling `openat`, rather than returning `EIO` after an OS failure or working-directory operation. This check does not establish the validity of arbitrary nonnegative integers: callers must supply live descriptors and retain them until synchronous calls return or asynchronous operations settle.
115
+
110
116
  `replaceDirectoryAtomic()` requires `renameNoReplaceWithIdentity` before it
111
117
  creates a missing target parent. On POSIX the dedicated entry point keeps the
112
118
  existing pre-dispatch exact receipt fence but dispatches direct-child names
package/docs/root.md CHANGED
@@ -24,7 +24,7 @@ type RootDefaults = {
24
24
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
25
25
  maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
26
26
  mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
27
- mode?: number; // file mode applied to new writes; per-call override available
27
+ mode?: number; // requested file mode; per-call override available
28
28
  nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
29
29
  renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
30
30
  symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
@@ -117,6 +117,10 @@ created through a directory symlink or Windows junction. An absolute path
117
117
  outside the root is still rejected. On Windows, alternate casing is accepted
118
118
  only when the differently cased Root prefix has the Root's exact directory
119
119
  identity; the operation then continues under the trusted Root spelling.
120
+ Absolute paths keep literal `~` components: `readAbsolute("/srv/root/~/file")`
121
+ reads that entry under the root, without expanding the user's home directory.
122
+ Relative `~/file` inputs still expand the home directory and must remain inside
123
+ the Root; use `./~/file` for a literal relative `~` directory.
120
124
 
121
125
  ### Writes
122
126
 
@@ -153,6 +157,11 @@ await fs.create("private-data/credential", "synthetic credential", { private: tr
153
157
 
154
158
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
155
159
 
160
+ For `append` and `openWritable`, `mode` only affects new-file creation: POSIX
161
+ permissions remain subject to the process umask. These methods do not chmod
162
+ existing files. Replacement and create-only writes apply their final mode
163
+ through the retained descriptor; see [Writing](writing.md#write-options).
164
+
156
165
  Buffered `create` and `createJson` also accept `atomic?: boolean`. With `true`,
157
166
  complete content is staged before exclusive publication even in native-off mode;
158
167
  the fallback requires hardlinks. Omitted or `false` keeps the existing buffered
@@ -228,6 +228,15 @@ if anything already occupies the target path it throws
228
228
  `FsSafeError("secret-exists")` without modifying that entry. Use the distinct
229
229
  name when first-writer-wins is part of the credential protocol.
230
230
 
231
+ Its `durable` option also accepts `"file"`, matching `Root.create()`. This
232
+ requires every file `fsync` to succeed, including on `EPERM`, while parent-directory
233
+ synchronization remains best effort. The default and boolean options retain their
234
+ existing behavior. `writeSecretFileAtomic()` continues to accept boolean durability.
235
+
236
+ Strict file synchronization preserves the existing publication strategy and
237
+ identity-checked cleanup. A failed file flush before staged publication prevents
238
+ publication; a failure after publication can leave the complete file present.
239
+
231
240
  Distinct leaves can share missing-parent creation without a `secret-exists`
232
241
  error. Concurrent creates at the same leaf still have exactly one winner;
233
242
  the loser receives `secret-exists` and leaves the winner's bytes intact.
@@ -244,6 +253,7 @@ try {
244
253
  rootDir: "/var/lib/app/credentials",
245
254
  filePath: "/var/lib/app/credentials/provider.refresh-token",
246
255
  content: refreshToken,
256
+ durable: "file",
247
257
  });
248
258
  } catch (error) {
249
259
  if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
package/docs/walk.md CHANGED
@@ -113,6 +113,18 @@ typed `FsSafeError("too-large")` instead.
113
113
 
114
114
  For followed symlinks, both `kind` and `size` describe the resolved target.
115
115
 
116
+ The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
117
+ the home directory when iteration starts and must resolve inside the Root.
118
+ Home-started walks report actual Root-relative paths, such as `home/dir/file`,
119
+ rather than `~/dir/file`. Use `./~/dir` to start at a literal `~` directory.
120
+ An alias within a home-started path is reported under its admitted canonical
121
+ target; ordinary non-home starting aliases retain their caller-supplied spelling.
122
+
123
+ Entry names remain literal filesystem data, including a directory named `~`
124
+ and its descendants. To reuse an entry path in another Root method without
125
+ home-directory expansion, prefix it with `./`, as in
126
+ `capability.open("./" + entry.relativePath)`.
127
+
116
128
  The default `order: "sorted"` visits each directory's names in lexicographic
117
129
  order before descending depth first. It reads and sorts all names in each
118
130
  visited directory. With `maxEntries`, it prepares small metadata batches capped
package/docs/writing.md CHANGED
@@ -301,6 +301,12 @@ type RootWriteJsonOptions = RootWriteOptions & {
301
301
 
302
302
  Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. `durable: false` skips both syncs. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
303
303
 
304
+ `mode` selects the creation mode, defaulting to `0o600` when neither the call nor
305
+ the Root supplies it. On POSIX, the process umask can further restrict that mode;
306
+ for example, `mode: 0o640` with umask `0o077` creates a `0o600` file. Existing
307
+ files are not chmodded, even when an explicit `mode` is supplied. Empty appends
308
+ use the same creation rules.
309
+
304
310
  ```ts
305
311
  await fs.append("logs/today.log", `[${ts}] ${line}\n`);
306
312
  await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
@@ -522,6 +528,11 @@ destination — there is no atomic-rename step. For exclusive publication of a
522
528
  complete stream, use [`create()`](#streamed-creation). For streamed replacement,
523
529
  the [`atomic`](atomic.md) helpers provide a staged writer.
524
530
 
531
+ For all three write modes, `mode` only selects new-file creation permissions,
532
+ defaulting to `0o600` when neither the call nor the Root supplies it. POSIX
533
+ permissions remain subject to the process umask; existing files are not chmodded.
534
+ The returned numeric `stat` records the admitted descriptor before caller writes.
535
+
525
536
  On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
526
537
  a no-reader FIFO cannot stall regular-file validation. This does not change
527
538
  ordinary regular-file write semantics. `replace` and `update` remain write-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -169,13 +169,13 @@
169
169
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
170
170
  },
171
171
  "optionalDependencies": {
172
- "@openclaw/fs-safe-darwin-arm64": "0.18.2",
173
- "@openclaw/fs-safe-darwin-x64": "0.18.2",
174
- "@openclaw/fs-safe-linux-arm64-gnu": "0.18.2",
175
- "@openclaw/fs-safe-linux-arm64-musl": "0.18.2",
176
- "@openclaw/fs-safe-linux-x64-gnu": "0.18.2",
177
- "@openclaw/fs-safe-linux-x64-musl": "0.18.2",
178
- "@openclaw/fs-safe-win32-x64-msvc": "0.18.2",
172
+ "@openclaw/fs-safe-darwin-arm64": "0.19.0",
173
+ "@openclaw/fs-safe-darwin-x64": "0.19.0",
174
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
175
+ "@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
176
+ "@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
177
+ "@openclaw/fs-safe-linux-x64-musl": "0.19.0",
178
+ "@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
179
179
  "jszip": "^3.10.2"
180
180
  },
181
181
  "devDependencies": {