From 7d2923979941fa17d9bf12a81f911fe59c820bf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 15:31:08 -0700 Subject: [PATCH] fix(write): tolerate raw stat modes with file-type bits in dirMode 0.8.0 regressed input tolerance for replaceFileAtomic({ dirMode }): a raw fs.stat mode such as 0o40755 (S_IFDIR | 0o755) was compared unmasked against the pinned directory's masked mode (0o7777), failing with 'directory final mode could not be verified' even though chmod ignores file-type bits. Mask requested directory modes to 0o7777 at the ownDirectoryMode.apply comparison point (covering applyDirectoryMode, sibling-temp, secret-file, and archive-merge) and before the synchronous fchmod in applyDirectoryModeSync. Add a regression test passing a raw stat mode through async and sync replaceFileAtomic. --- CHANGELOG.md | 1 + src/directory-mode-owner.ts | 3 +++ src/replace-file-descriptor.ts | 3 ++- test/atomic-dirmode-regression.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71bb721..2af7c300 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 8421f34c..9e6f91c8 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 a60c230b..d669e105 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 1016068c..5c6222b9 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");