@openclaw/fs-safe 0.18.2 → 0.20.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 (124) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +15 -5
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-durability.js +1 -1
  6. package/dist/archive-merge.js +1 -1
  7. package/dist/archive-plan.d.ts +2 -7
  8. package/dist/archive-read.js +9 -18
  9. package/dist/archive-staging.js +3 -1
  10. package/dist/archive-zip-entry.d.ts +11 -11
  11. package/dist/archive-zip-entry.js +3 -35
  12. package/dist/archive-zip-integrity.d.ts +2 -2
  13. package/dist/archive-zip-integrity.js +2 -12
  14. package/dist/archive-zip-loader.d.ts +7 -3
  15. package/dist/archive-zip-loader.js +10 -9
  16. package/dist/archive-zip-preflight.d.ts +2 -1
  17. package/dist/archive-zip-preflight.js +16 -7
  18. package/dist/archive.js +17 -16
  19. package/dist/directory-receipt.js +5 -7
  20. package/dist/effective-uid.js +1 -4
  21. package/dist/errors.d.ts +3 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/file-lock-sync-root-held.d.ts +4 -11
  24. package/dist/file-lock-sync-root-held.js +1 -4
  25. package/dist/file-lock-sync-root-io.d.ts +1 -4
  26. package/dist/file-lock-sync-root.d.ts +2 -4
  27. package/dist/file-store-boundary.d.ts +3 -7
  28. package/dist/file-store-boundary.js +7 -10
  29. package/dist/file-store-prune.js +3 -2
  30. package/dist/file-store.d.ts +4 -7
  31. package/dist/file-store.js +19 -21
  32. package/dist/guest-native-python.js +23 -31
  33. package/dist/guest.js +21 -12
  34. package/dist/json-document-store.d.ts +4 -9
  35. package/dist/json-durable-queue.js +2 -6
  36. package/dist/local-file-access.js +2 -5
  37. package/dist/local-file-descriptor.d.ts +2 -5
  38. package/dist/local-roots.d.ts +2 -7
  39. package/dist/move-path-cleanup.js +4 -4
  40. package/dist/native-binding.d.ts +18 -14
  41. package/dist/native-staged-symlink.d.ts +13 -0
  42. package/dist/native-staged-symlink.js +303 -0
  43. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  44. package/dist/owner-dacl-batch-worker.js +54 -0
  45. package/dist/owner-dacl-batch.d.ts +5 -0
  46. package/dist/owner-dacl-batch.js +64 -0
  47. package/dist/owner-dacl.d.ts +2 -0
  48. package/dist/owner-dacl.js +3 -0
  49. package/dist/path.js +17 -1
  50. package/dist/permission-exec.js +3 -6
  51. package/dist/permissions-public.d.ts +1 -0
  52. package/dist/permissions-public.js +1 -0
  53. package/dist/pinned-mutation-admission.d.ts +0 -1
  54. package/dist/pinned-mutation-shared-route.d.ts +2 -8
  55. package/dist/pinned-open.d.ts +0 -1
  56. package/dist/pinned-open.js +1 -2
  57. package/dist/publish-copy-stage.js +4 -0
  58. package/dist/read-opened-file.d.ts +2 -5
  59. package/dist/regular-file.js +3 -3
  60. package/dist/replace-file-copy-fallback.d.ts +1 -2
  61. package/dist/root-context.js +3 -2
  62. package/dist/root-impl.js +0 -3
  63. package/dist/root-move-noreplace.d.ts +2 -7
  64. package/dist/root-observed-path.d.ts +0 -1
  65. package/dist/root-observed-path.js +0 -3
  66. package/dist/root-path-observation.d.ts +4 -11
  67. package/dist/root-path.js +7 -10
  68. package/dist/root-paths.d.ts +2 -6
  69. package/dist/root-remove-identity.d.ts +1 -3
  70. package/dist/root-walk.js +8 -3
  71. package/dist/root-write-admission.js +1 -6
  72. package/dist/root-write-complete-parent.d.ts +2 -0
  73. package/dist/root-write-complete-parent.js +1 -1
  74. package/dist/safe-path-segment.d.ts +1 -0
  75. package/dist/safe-path-segment.js +8 -2
  76. package/dist/secret-file.d.ts +6 -2
  77. package/dist/secret-file.js +1 -0
  78. package/dist/secure-file-windows.js +1 -5
  79. package/dist/secure-file.js +3 -2
  80. package/dist/sidecar-lock-admission-parser.d.ts +1 -2
  81. package/dist/sidecar-lock-handle.d.ts +2 -8
  82. package/dist/sidecar-lock-policy.d.ts +2 -7
  83. package/dist/sidecar-lock-stale-admission.d.ts +1 -5
  84. package/dist/sidecar-lock.js +5 -3
  85. package/dist/staged-symlink-types.d.ts +49 -0
  86. package/dist/staged-symlink-types.js +1 -0
  87. package/dist/symlink-parents.js +58 -7
  88. package/dist/temp-target.js +4 -2
  89. package/dist/temp-workspace-owner.js +4 -9
  90. package/dist/test-hooks.d.ts +1 -1
  91. package/dist/text-atomic.d.ts +2 -1
  92. package/dist/text-atomic.js +2 -0
  93. package/dist/trash.js +27 -1
  94. package/dist/walk.d.ts +2 -5
  95. package/dist/windows-owner.d.ts +0 -1
  96. package/dist/windows-owner.js +0 -1
  97. package/dist/windows-security-bridge.cs +6 -4
  98. package/dist/windows-security-bridge.ps1 +78 -3
  99. package/dist/windows-security-command.d.ts +8 -0
  100. package/dist/windows-security-command.js +66 -15
  101. package/dist/windows-security-facts.d.ts +4 -0
  102. package/dist/windows-security-facts.js +6 -2
  103. package/docs/advanced.md +3 -2
  104. package/docs/archive.md +10 -0
  105. package/docs/atomic.md +11 -3
  106. package/docs/contributing.md +30 -0
  107. package/docs/copy.md +2 -0
  108. package/docs/file-store.md +5 -0
  109. package/docs/guest.md +7 -1
  110. package/docs/install.md +28 -0
  111. package/docs/native-helper.md +11 -4
  112. package/docs/native.md +45 -1
  113. package/docs/permissions.md +66 -0
  114. package/docs/public-api.md +7 -1
  115. package/docs/root.md +10 -1
  116. package/docs/secret-file.md +10 -0
  117. package/docs/security-model.md +4 -1
  118. package/docs/sidecar-lock.md +2 -0
  119. package/docs/staged-symlink.md +123 -0
  120. package/docs/store.md +3 -1
  121. package/docs/testing.md +28 -0
  122. package/docs/walk.md +12 -0
  123. package/docs/writing.md +21 -0
  124. package/package.json +9 -9
@@ -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;
@@ -53,9 +53,6 @@ export function getRootSyncHeldLocks() {
53
53
  }
54
54
  return globalWithState[ROOT_SYNC_HELD_LOCKS_KEY];
55
55
  }
56
- function existingRootSyncHeldLocks() {
57
- return globalThis[ROOT_SYNC_HELD_LOCKS_KEY];
58
- }
59
56
  function reactivateExactHeldLock(heldLocks, held, restoreConsumedReference) {
60
57
  if (heldLocks.get(held.normalizedTargetPath) !== held || held.releaseState === "released") {
61
58
  return false;
@@ -123,7 +120,7 @@ function releaseAllRootSyncHeldLocks() {
123
120
  try {
124
121
  // A newListener callback can invoke the candidate handler before
125
122
  // registration succeeds. Do not create acquisition state in that window.
126
- const heldLocks = existingRootSyncHeldLocks();
123
+ const heldLocks = globalThis[ROOT_SYNC_HELD_LOCKS_KEY];
127
124
  if (!heldLocks)
128
125
  return;
129
126
  getSyncLockAdmissions().clear();
@@ -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);
@@ -12,14 +12,11 @@ export type FileStoreOptions = {
12
12
  mode?: number;
13
13
  maxBytes?: number;
14
14
  };
15
- export type FileStoreWriteOptions = {
15
+ export type FileStoreWriteOptions = Partial<Pick<FileStoreOptions, "dirMode" | "mode" | "maxBytes"> & {
16
16
  /** Override store durability; defaults to the store option, then true. */
17
- durable?: boolean;
18
- dirMode?: number;
19
- mode?: number;
20
- maxBytes?: number;
21
- tempPrefix?: string;
22
- };
17
+ durable: boolean;
18
+ tempPrefix: string;
19
+ }>;
23
20
  export type FileStoreReadOptions = RootReadOptions & {
24
21
  encoding?: BufferEncoding;
25
22
  };
@@ -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)",
@@ -1,13 +1,8 @@
1
- import type { FileLockRetryOptions } from "./file-lock.js";
1
+ import { type FsSafeLockConfig } from "./lock-config.js";
2
2
  import type { Root } from "./root.js";
3
- import type { SidecarLockStaleRecovery } from "./sidecar-lock.js";
4
- export type JsonStoreLockOptions = {
5
- staleMs?: number;
6
- timeoutMs?: number;
7
- retry?: FileLockRetryOptions;
8
- staleRecovery?: SidecarLockStaleRecovery;
9
- managerKey?: string;
10
- };
3
+ export type JsonStoreLockOptions = Partial<FsSafeLockConfig & {
4
+ managerKey: string;
5
+ }>;
11
6
  export type JsonFileStoreOptions = {
12
7
  /** Overrides the parent store's durability for every JSON mutation. */
13
8
  durable?: boolean;
@@ -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) {
@@ -8,10 +8,6 @@ const FILE_URL_PREFIX_RE = /^file:\/\//i;
8
8
  export function isFileUrl(input) {
9
9
  return FILE_URL_PREFIX_RE.test(input);
10
10
  }
11
- function isLocalFileUrlHost(hostname) {
12
- const normalized = normalizeLowercaseStringOrEmpty(hostname);
13
- return normalized === "" || normalized === "localhost";
14
- }
15
11
  export function hasEncodedFileUrlSeparator(pathname) {
16
12
  return ENCODED_FILE_URL_SEPARATOR_RE.test(pathname);
17
13
  }
@@ -41,7 +37,8 @@ export function safeFileURLToPath(fileUrl, platform = process.platform) {
41
37
  if (parsed.protocol !== "file:") {
42
38
  throw new Error(`Invalid file:// URL: ${fileUrl}`);
43
39
  }
44
- if (!isLocalFileUrlHost(parsed.hostname)) {
40
+ const normalizedHost = normalizeLowercaseStringOrEmpty(parsed.hostname);
41
+ if (!(normalizedHost === "" || normalizedHost === "localhost")) {
45
42
  throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
46
43
  }
47
44
  if (hasEncodedFileUrlSeparator(parsed.pathname)) {
@@ -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>;
@@ -135,9 +135,6 @@ async function observeOwnedAliasUnlink(sourcePath, group) {
135
135
  group.expected = entryIdentity(observed);
136
136
  return "removed";
137
137
  }
138
- function mergeCleanupResults(a, b) {
139
- return a === "stale" || b === "stale" ? "stale" : "removed";
140
- }
141
138
  export async function cleanupCopiedEntry(sourcePath, manifest, state, assertBeforeMutation) {
142
139
  if (manifest.kind === "directory") {
143
140
  let currentStat;
@@ -162,7 +159,10 @@ export async function cleanupCopiedEntry(sourcePath, manifest, state, assertBefo
162
159
  inspectSourceDirectory(sourcePath, manifest.directoryIdentity);
163
160
  } : undefined;
164
161
  for (const child of manifest.children) {
165
- result = mergeCleanupResults(result, await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest, state, assertBeforeChildMutation));
162
+ const childResult = await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest, state, assertBeforeChildMutation);
163
+ if (childResult === "stale") {
164
+ result = "stale";
165
+ }
166
166
  }
167
167
  // Child cleanup and the caller's authority check can replace the directory.
168
168
  // Keep the final exact observation after both, immediately before removal.