Skip to content

Commit 4696341

Browse files
authored
fix(atomic): close descriptor mode gaps (#100)
Stress testing the atomic replacement surface found four defects. Synchronous adapters without fchmodSync returned before the no-follow parent identity check ran, so they wrote through symlinked parents. Validation now always runs and only the mode application is conditional. The copy fallback fsynced the disposable temp file rather than the published destination, so a durability request did not cover the bytes that survived. Ordering is corrected on both the async and sync paths. A mode failure during the fallback could leave replacement bytes in place despite restore-original; restoration now covers the original bytes and the original mode. Removing chmod and chmodSync from the injectable filesystem type broke fresh strict-TypeScript adapter literals that still listed them. They are accepted again as deprecated, unused compatibility fields. Windows keeps explicit content, sync-order, publication, and descriptor-close coverage where POSIX modes are not enforced, and the POSIX cases retain their exact mode assertions. Escalated separately: under umask 0777 a newly created directory lands at mode 000 and cannot be reopened for descriptor-bound mode application. Fixing that needs descriptor-relative creation rather than a pathname chmod.
1 parent 9bb8465 commit 4696341

8 files changed

Lines changed: 377 additions & 17 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Security and Correctness
66

7+
- Keep the POSIX parent-directory no-follow identity check active for synchronous atomic-replacement adapters that omit `fchmodSync`, preventing the documented default adapter path from writing through a symlinked parent.
8+
- Synchronize the actual destination after descriptor-bound mode application when atomic rename uses copy fallback, and include both bytes and mode in bounded fallback restoration.
9+
- Continue accepting legacy atomic-replacement adapter literals that expose pathname `chmod` or `chmodSync` methods while keeping those methods unused.
10+
711
- Reject non-file sidecars without spinning, and read contended async and synchronous sidecar locks through bounded, no-follow, identity-checked descriptors; keep valid `createdAt` timestamps authoritative under filesystem clock skew; fail closed when fallback Windows ACL inspection returns no verifiable access entries; preserve synchronous stale-reclaim guards owned by another acquirer; and share one process-exit cleanup listener across file-lock manager domains.
812

913
- Route `Root.copyIn()` and overwrite-capable `Root.write()` commits through a new descriptor-relative native replace rename, closing a parent-symlink swap that could create a missing destination directory outside the root before the JavaScript fallback detected the escape. Native `auto` and `require` mode now protect both create-only and replacing pinned writes; the explicitly best-effort JavaScript fallback remains available in `off` mode or when `auto` cannot load a binding.

docs/atomic.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ type ReplaceFileAtomicOptions = {
4848
copyFallbackRestore?: "restore-original" | "none"; // default: "none"
4949
maxRestoreBytes?: number; // required with "restore-original"
5050
destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
51-
syncTempFile?: boolean; // fsync(temp) before rename; default false
52-
syncParentDir?: boolean; // fsync(parent) after rename; default false
51+
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
52+
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
5353
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
5454
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
5555
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
@@ -90,9 +90,10 @@ The default `copyFallbackRestore: "none"` preserves the existing fallback
9090
contract: a failed copy can leave a partial destination. For state files where
9191
preserving the old bytes is more important, choose `"restore-original"` and set
9292
an explicit `maxRestoreBytes` memory budget. If the destination exists, fs-safe
93-
snapshots it through a pinned descriptor, overwrites through that same
94-
descriptor, and synchronizes the result. Any write or sync failure triggers a
95-
restore and another sync through the same descriptor.
93+
snapshots it through a pinned descriptor, overwrites and mode-adjusts through
94+
that same descriptor, and synchronizes the result. Any write, mode, or sync
95+
failure triggers a byte-and-mode restore and another sync through the same
96+
descriptor.
9697

9798
Restore failures are `FsSafeError("helper-failed")` values with typed
9899
`details.cleanup` set to `"restored"` or `"restore-failed"`. An original larger
@@ -163,7 +164,11 @@ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
163164
copying into a staged sibling path, renaming that staged path into place, and
164165
then removing only the source entries that were copied. The fallback avoids
165166
buffering regular files into memory and does not tighten the destination parent
166-
directory mode.
167+
directory mode. Staged file modes are applied through their still-open handles.
168+
On POSIX, staged directory modes are applied through no-follow directory
169+
descriptors; on Windows, Node cannot portably open those descriptors and no
170+
pathname `chmod` fallback is attempted, so directory modes remain subject to
171+
Windows' `mkdir(mode)` behavior.
167172

168173
```ts
169174
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
@@ -227,7 +232,7 @@ type ReplaceFileAtomicSyncFileSystem = {
227232
};
228233
```
229234

230-
The async interface already requires `open()`, whose `FileHandle` supplies `chmod()`, so injecting `node:fs` or another conforming adapter needs no new async member. On POSIX, that `open()` must support no-follow directory descriptors as Node does. A custom synchronous filesystem that passes `mode`, `dirMode`, or `preserveExistingMode` must supply `fchmodSync`; omission fails before any file or directory is created and never falls back to a pathname `chmod`. Existing synchronous adapters that request none of those options may omit it. Injecting plain `node:fs` supports explicit file and directory modes. Copy fallback applies the file mode through its pinned destination descriptor as well, preserving exact modes despite the process umask.
235+
The async interface already requires `open()`, whose `FileHandle` supplies `chmod()`, so injecting `node:fs` or another conforming adapter needs no new async member. On POSIX, that `open()` must support no-follow directory descriptors as Node does. A custom synchronous filesystem that passes `mode`, `dirMode`, or `preserveExistingMode` must supply `fchmodSync`; omission fails before any file or directory is created and never falls back to a pathname `chmod`. Existing synchronous adapters that request none of those options may omit it; their parent is still opened and identity-checked through a no-follow directory descriptor. Injecting plain `node:fs` supports explicit file and directory modes. Older adapter literals may continue to include `chmod` or `chmodSync` for source compatibility, but those operations are ignored. Copy fallback applies the file mode through its pinned destination descriptor as well, preserving exact modes despite the process umask.
231236

232237
## See also
233238

src/replace-file-copy-fallback.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,18 @@ async function replacePinnedWithRestore(
250250
handle: FileHandle,
251251
replacement: Buffer,
252252
maxRestoreBytes: number,
253+
replacementMode: number,
253254
): Promise<void> {
255+
const originalMode = (await handle.stat()).mode;
254256
const original = await readBounded(handle, maxRestoreBytes);
255257
try {
256258
await writeAll(handle, replacement);
259+
await handle.chmod(replacementMode);
257260
await handle.sync();
258261
} catch (writeError) {
259262
try {
260263
await writeAll(handle, original);
264+
await handle.chmod(originalMode);
261265
await handle.sync();
262266
throw restoreFailure(writeError, "restored");
263267
} catch (restoreError) {
@@ -274,14 +278,19 @@ function replacePinnedWithRestoreSync(
274278
fd: number,
275279
replacement: Buffer,
276280
maxRestoreBytes: number,
281+
replacementMode: number,
282+
fchmodSync?: (fd: number, mode: number) => void,
277283
): void {
284+
const originalMode = fsModule.fstatSync(fd).mode;
278285
const original = readBoundedSync(fsModule, fd, maxRestoreBytes);
279286
try {
280287
writeAllSync(fsModule, fd, replacement);
288+
fchmodSync?.(fd, replacementMode);
281289
fsModule.fsyncSync(fd);
282290
} catch (writeError) {
283291
try {
284292
writeAllSync(fsModule, fd, original);
293+
fchmodSync?.(fd, originalMode);
285294
fsModule.fsyncSync(fd);
286295
throw restoreFailure(writeError, "restored");
287296
} catch (restoreError) {
@@ -300,6 +309,7 @@ export async function copyFallbackReplace(params: {
300309
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
301310
restore: ReplaceFileCopyFallbackRestorePolicy;
302311
maxRestoreBytes?: number;
312+
sync: boolean;
303313
}): Promise<void> {
304314
const sourcePreview = await params.fsModule.lstat(params.src);
305315
if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
@@ -323,7 +333,12 @@ export async function copyFallbackReplace(params: {
323333
);
324334
if (pinned) {
325335
destHandle = pinned.handle;
326-
await replacePinnedWithRestore(destHandle, replacement, params.maxRestoreBytes!);
336+
await replacePinnedWithRestore(
337+
destHandle,
338+
replacement,
339+
params.maxRestoreBytes!,
340+
sourceStat.mode,
341+
);
327342
}
328343
}
329344

@@ -349,8 +364,11 @@ export async function copyFallbackReplace(params: {
349364
sourceStat.mode & 0o777,
350365
);
351366
await destHandle.writeFile(replacement);
367+
await destHandle.chmod(sourceStat.mode);
368+
if (params.sync) {
369+
await destHandle.sync();
370+
}
352371
}
353-
await destHandle.chmod(sourceStat.mode);
354372
} finally {
355373
await destHandle?.close().catch(() => undefined);
356374
await sourceHandle.close().catch(() => undefined);
@@ -366,6 +384,7 @@ export function copyFallbackReplaceSync(params: {
366384
restore: ReplaceFileCopyFallbackRestorePolicy;
367385
maxRestoreBytes?: number;
368386
fchmodSync?: (fd: number, mode: number) => void;
387+
sync: boolean;
369388
}): void {
370389
const sourcePreview = params.fsModule.lstatSync(params.src);
371390
if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
@@ -394,6 +413,8 @@ export function copyFallbackReplaceSync(params: {
394413
destFd,
395414
replacement,
396415
params.maxRestoreBytes!,
416+
sourceStat.mode,
417+
params.fchmodSync,
397418
);
398419
}
399420
}
@@ -422,8 +443,11 @@ export function copyFallbackReplaceSync(params: {
422443
sourceStat.mode & 0o777,
423444
);
424445
writeAllSync(params.fsModule, destFd, replacement);
446+
params.fchmodSync?.(destFd, sourceStat.mode);
447+
if (params.sync) {
448+
params.fsModule.fsyncSync(destFd);
449+
}
425450
}
426-
params.fchmodSync?.(destFd, sourceStat.mode);
427451
} finally {
428452
if (destFd !== undefined) {
429453
try {

src/replace-file-descriptor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function applyDirectoryModeSync(params: {
6565
mode: number;
6666
fchmodSync?: SyncFchmod;
6767
}): void {
68-
if (process.platform === "win32" || !params.fchmodSync) {
68+
if (process.platform === "win32") {
6969
return;
7070
}
7171

@@ -74,7 +74,7 @@ export function applyDirectoryModeSync(params: {
7474
const fd = params.fsModule.openSync(params.dirPath, directoryOpenFlags());
7575
try {
7676
assertSameDirectory(expected, params.fsModule.fstatSync(fd), params.dirPath);
77-
params.fchmodSync(fd, params.mode);
77+
params.fchmodSync?.(fd, params.mode);
7878
} finally {
7979
params.fsModule.closeSync(fd);
8080
}

src/replace-file.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export type ReplaceFileAtomicFileSystem = {
3636
| "open"
3737
| "stat"
3838
| "lstat"
39-
>;
39+
> & {
40+
/** @deprecated Accepted for adapter compatibility but never called. */
41+
chmod?: typeof fs.chmod;
42+
};
4043
};
4144

4245
export type ReplaceFileAtomicSyncFileSystem = Pick<
@@ -58,6 +61,8 @@ export type ReplaceFileAtomicSyncFileSystem = Pick<
5861
| "readSync"
5962
| "writeSync"
6063
> & {
64+
/** @deprecated Accepted for adapter compatibility but never called. */
65+
chmodSync?: typeof syncFs.chmodSync;
6166
fchmodSync?: typeof syncFs.fchmodSync;
6267
};
6368

@@ -123,6 +128,7 @@ async function renameWithRetry(params: {
123128
copyFallbackRestore: ReplaceFileCopyFallbackRestorePolicy;
124129
maxRestoreBytes?: number;
125130
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
131+
syncFallback: boolean;
126132
}): Promise<ReplaceFileAtomicResult> {
127133
for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
128134
try {
@@ -141,6 +147,7 @@ async function renameWithRetry(params: {
141147
destinationHardlinks: params.destinationHardlinks,
142148
restore: params.copyFallbackRestore,
143149
maxRestoreBytes: params.maxRestoreBytes,
150+
sync: params.syncFallback,
144151
});
145152
return { method: "copy-fallback" };
146153
}
@@ -168,6 +175,7 @@ function renameWithRetrySync(params: {
168175
maxRestoreBytes?: number;
169176
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
170177
fchmodSync?: SyncFchmod;
178+
syncFallback: boolean;
171179
}): ReplaceFileAtomicResult {
172180
for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
173181
try {
@@ -187,6 +195,7 @@ function renameWithRetrySync(params: {
187195
restore: params.copyFallbackRestore,
188196
maxRestoreBytes: params.maxRestoreBytes,
189197
fchmodSync: params.fchmodSync,
198+
sync: params.syncFallback,
190199
});
191200
return { method: "copy-fallback" };
192201
}
@@ -331,9 +340,9 @@ async function replaceFileAtomicUnserialized(
331340
const unregisterTempPath = registerTempPathForExit(tempPath);
332341
let tempExists = false;
333342
let originalError: unknown;
334-
await fsModule.mkdir(dir, { recursive: true, mode: dirMode });
335-
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
336343
try {
344+
await fsModule.mkdir(dir, { recursive: true, mode: dirMode });
345+
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
337346
tempExists = true;
338347
unregisterTempPath.setIdentity(await writeTempFile({
339348
fsModule,
@@ -356,6 +365,7 @@ async function replaceFileAtomicUnserialized(
356365
copyFallbackRestore: options.copyFallbackRestore ?? "none",
357366
maxRestoreBytes: options.maxRestoreBytes,
358367
destinationHardlinks: options.destinationHardlinks,
368+
syncFallback: options.syncTempFile === true,
359369
});
360370
tempExists = false;
361371
unregisterTempPath();
@@ -404,9 +414,9 @@ export function replaceFileAtomicSync(
404414
const unregisterTempPath = registerTempPathForExit(tempPath);
405415
let tempExists = false;
406416
let originalError: unknown;
407-
fsModule.mkdirSync(dir, { recursive: true, mode: dirMode });
408-
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
409417
try {
418+
fsModule.mkdirSync(dir, { recursive: true, mode: dirMode });
419+
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
410420
tempExists = true;
411421
unregisterTempPath.setIdentity(writeTempFileSync({
412422
fsModule,
@@ -431,6 +441,7 @@ export function replaceFileAtomicSync(
431441
maxRestoreBytes: options.maxRestoreBytes,
432442
destinationHardlinks: options.destinationHardlinks,
433443
fchmodSync,
444+
syncFallback: options.syncTempFile === true,
434445
});
435446
tempExists = false;
436447
unregisterTempPath();

test/atomic-dirmode-regression.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,26 @@ describe("atomic parent-directory descriptor modes", () => {
206206
await expect(fs.access(path.dirname(filePath))).rejects.toMatchObject({ code: "ENOENT" });
207207
},
208208
);
209+
210+
it.runIf(process.platform !== "win32")(
211+
"rejects a symlinked parent with a sync adapter that omits fchmodSync",
212+
async () => {
213+
const root = await tempRoot("fs-safe-atomic-dirmode-symlink-");
214+
const outside = await tempRoot("fs-safe-atomic-dirmode-outside-");
215+
const linkedDir = path.join(root, "linked");
216+
const filePath = path.join(linkedDir, "state.txt");
217+
const { fchmodSync: _fchmodSync, ...syncWithoutFchmod } = fsSync;
218+
await fs.symlink(outside, linkedDir, "dir");
219+
220+
expect(() => replaceFileAtomicSync({
221+
filePath,
222+
content: "must not escape",
223+
fileSystem: syncWithoutFchmod,
224+
})).toThrow("Atomic replace parent must be a real directory");
225+
226+
await expect(fs.access(path.join(outside, "state.txt"))).rejects.toMatchObject({
227+
code: "ENOENT",
228+
});
229+
},
230+
);
209231
});

0 commit comments

Comments
 (0)