diff --git a/CHANGELOG.md b/CHANGELOG.md index d71bb72..2af7c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/directory-mode-owner.ts b/src/directory-mode-owner.ts index 8421f34..9e6f91c 100644 --- a/src/directory-mode-owner.ts +++ b/src/directory-mode-owner.ts @@ -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?.(); diff --git a/src/replace-file-descriptor.ts b/src/replace-file-descriptor.ts index a60c230..d669e10 100644 --- a/src/replace-file-descriptor.ts +++ b/src/replace-file-descriptor.ts @@ -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); } diff --git a/test/atomic-dirmode-regression.test.ts b/test/atomic-dirmode-regression.test.ts index 1016068..5c6222b 100644 --- a/test/atomic-dirmode-regression.test.ts +++ b/test/atomic-dirmode-regression.test.ts @@ -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");