Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Fix `replaceFileAtomic({ dirMode })` rejecting a raw `fs.stat` mode: directory modes are masked to permission bits (`0o7777`) before application and verification, matching chmod semantics; 0.8.0 regressed this input tolerance with `directory final mode could not be verified`.
- Add `retainOnExit` to sidecar lock acquisition so deliberately retained ownership records (for example fail-closed build locks) survive natural process exit; default process-exit release behavior is unchanged.

## 0.8.0 - 2026-09-04
Expand Down
3 changes: 3 additions & 0 deletions src/directory-mode-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function ownDirectoryMode(params: {
check?.();
}),
apply: (mode, checks = {}) => enqueue(async () => {
// inspect() reports permission bits only; chmod ignores file-type bits,
// so tolerate raw stat modes (e.g. S_IFDIR | 0o755) by masking up front.
mode &= 0o7777;
checks.check?.();
const currentMode = await params.inspect();
checks.check?.();
Expand Down
3 changes: 2 additions & 1 deletion src/replace-file-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export function applyDirectoryModeSync(params: {
const fd = params.fsModule.openSync(params.dirPath, directoryOpenFlags());
try {
assertSameDirectory(expected, params.fsModule.fstatSync(fd), params.dirPath);
params.fchmodSync?.(fd, params.mode);
// chmod ignores file-type bits; mask so raw stat modes are tolerated.
params.fchmodSync?.(fd, params.mode & 0o7777);
} finally {
params.fsModule.closeSync(fd);
}
Expand Down
24 changes: 24 additions & 0 deletions test/atomic-dirmode-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ describe("atomic parent-directory descriptor modes", () => {
}).toEqual({ targetMode: 0o751, victimMode: 0o755, content: "sync" });
});

itPosix("accepts a raw stat mode with file-type bits as dirMode", async () => {
const root = await tempRoot("fs-safe-atomic-dirmode-raw-stat-");
const asyncDir = path.join(root, "async");
const syncDir = path.join(root, "sync");
const asyncPath = path.join(asyncDir, "state.txt");
const syncPath = path.join(syncDir, "state.txt");
await fs.mkdir(asyncDir);
fsSync.mkdirSync(syncDir);
await fs.chmod(asyncDir, 0o755);
fsSync.chmodSync(syncDir, 0o755);
// Callers legitimately pass stat.mode verbatim; S_IFDIR bits must be tolerated.
const asyncRawMode = (await fs.stat(asyncDir)).mode;
const syncRawMode = fsSync.statSync(syncDir).mode;
expect(asyncRawMode & fsSync.constants.S_IFDIR).not.toBe(0);

await replaceFileAtomic({ filePath: asyncPath, content: "async", dirMode: asyncRawMode });
replaceFileAtomicSync({ filePath: syncPath, content: "sync", dirMode: syncRawMode });

expect(await fs.readFile(asyncPath, "utf8")).toBe("async");
expect(fsSync.readFileSync(syncPath, "utf8")).toBe("sync");
expect((await fs.stat(asyncDir)).mode & 0o777).toBe(0o755);
expect(fsSync.statSync(syncDir).mode & 0o777).toBe(0o755);
});

it("accepts plain node:fs injection with explicit async and sync dirMode", async () => {
const root = await tempRoot("fs-safe-atomic-dirmode-node-fs-");
const asyncDir = path.join(root, "async");
Expand Down