diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index fe9f5bd1..1f1a0273 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,15 +1,16 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2519, + "totalOccurrences": 2516, "filesAffected": 263, - "hotpathOccurrences": 1193, + "hotpathOccurrences": 1190, "hotpathFilesAffected": 156, "topHotpathFiles": [ { "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, + "count": 36, "calls": [ + "chmodSync", "closeSync", "fsyncSync", "linkSync", @@ -245,9 +246,24 @@ "markers": [] }, { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, + "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", + "count": 36, "calls": [ + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "rmSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 36, + "calls": [ + "chmodSync", "closeSync", "fsyncSync", "linkSync", @@ -263,20 +279,6 @@ ], "markers": [] }, - { - "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", - "count": 36, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "readFileSync", - "rmSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/cliproxy/executor/__tests__/composite-variant-service.test.ts", "count": 33, @@ -720,7 +722,7 @@ ] }, "largeFiles": { - "countOver400": 92, + "countOver400": 93, "countOver600": 42, "topOver400": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 69071af5..43e4b23c 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2519 | +| Sync fs occurrences (all) | 2516 | | Sync fs files affected (all) | 263 | -| Sync fs occurrences (runtime hotpaths) | 1193 | +| Sync fs occurrences (runtime hotpaths) | 1190 | | Sync fs files affected (runtime hotpaths) | 156 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -17,7 +17,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Sync Calls | API Names | |---|---:|---| -| `src/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/management/shared-manager/diverged-file-adopter.ts` | 36 | chmodSync, closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | @@ -62,7 +62,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | hotpath console.error/warn files | 81 | | files with createLogger | 65/765 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | -| files > 400 LOC | 92 | +| files > 400 LOC | 93 | | files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 43829a8f..1ac67e28 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -137,7 +137,42 @@ function validateManagedJson(filePath: string, content: Buffer): boolean { } } -function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void { +/** + * Identity of the canonical inode as it was read. Publication compares it + * again immediately before replacing the file, so a concurrent writer is + * detected instead of silently overwritten. + */ +interface CanonicalIdentity { + ino: number; + mtimeMs: number; + size: number; +} + +function readCanonicalIdentity(writePath: string): CanonicalIdentity | null { + const stats = getLstatSync(writePath); + if (!stats?.isFile()) return null; + return { ino: stats.ino, mtimeMs: stats.mtimeMs, size: stats.size }; +} + +function canonicalIdentityMatches( + expected: CanonicalIdentity | null, + current: CanonicalIdentity | null +): boolean { + if (!expected || !current) return expected === current; + return ( + expected.ino === current.ino && + expected.mtimeMs === current.mtimeMs && + expected.size === current.size + ); +} + +/** + * Create a file only if the path is free, writing the content atomically. + * + * link() is an atomic no-replace operation, so concurrent CCS processes + * cannot overwrite each other's sidecar artifacts. + */ +function createFileNoReplace(targetPath: string, content: Buffer, mode: number): void { const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; let descriptor: number | null = null; try { @@ -162,16 +197,17 @@ function atomicWriteFile(targetPath: string, content: Buffer, mode: number): voi } } -function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string { - const basePath = `${canonicalPath}.bak-ccs-adopt`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; +/** + * Publish a sidecar next to a managed file, never replacing an existing one. + * Numbered suffixes keep every concurrent writer's artifact recoverable. + */ +function publishSidecarNoReplace(basePath: string, content: Buffer, mode: number): string { let sequence = 0; while (true) { - const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + const sidecarPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; try { - atomicWriteFile(backupPath, content, mode); - return backupPath; + createFileNoReplace(sidecarPath, content, mode); + return sidecarPath; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; sequence++; @@ -179,43 +215,74 @@ function publishBackupNoReplace(sourcePath: string, canonicalPath: string): stri } } -function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string { - const basePath = `${divergedPath}.ccs-adopted-recovery`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; - let sequence = 0; - while (true) { - const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; - try { - atomicWriteFile(recoveryPath, content, mode); - return recoveryPath; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - sequence++; - } - } -} - -function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void { +/** + * Publish adopted content onto the canonical path by replacement. + * + * The canonical path is never emptied: a fully written temp file is renamed + * over it, so no window exists in which Claude Code or a second `ccs` can + * observe the path as missing and seed an empty placeholder there. + * + * A compare-and-swap guard runs as late as possible - after the temp file is + * durable, immediately before the rename. When the canonical inode changed + * since it was read, publication is refused rather than clobbering a writer + * that got there first; the adopted bytes stay in the sidecars the caller + * published. A pure chmod is not a content change, so the mode the inode + * carries at publication time wins. + */ +function publishCanonicalContent( + writePath: string, + content: Buffer, + mode: number, + expected: CanonicalIdentity | null +): void { + const tempPath = `${writePath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + let descriptor: number | null = null; try { - fs.linkSync(claimPath, writePath); - fs.unlinkSync(claimPath); + descriptor = fs.openSync(tempPath, 'wx', mode); + fs.fchmodSync(descriptor, mode); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + + const currentStats = getLstatSync(writePath); + const current = currentStats?.isFile() + ? { ino: currentStats.ino, mtimeMs: currentStats.mtimeMs, size: currentStats.size } + : null; + if (!canonicalIdentityMatches(expected, current)) { + throw Object.assign(new TypeError(`Canonical file changed during adoption: ${writePath}`), { + code: 'EEXIST', + }); + } + + const publishMode = currentStats ? currentStats.mode & 0o777 : mode; + if (publishMode !== mode) { + fs.chmodSync(tempPath, publishMode); + } + fs.renameSync(tempPath, writePath); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - publishBackupNoReplace(claimPath, canonicalPath); - fs.unlinkSync(claimPath); + if (descriptor !== null) { + fs.closeSync(descriptor); + } + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } + throw err; } } function getCanonicalFile(canonicalPath: string): { content: Buffer | null; + identity: CanonicalIdentity | null; mode: number; mtimeMs: number | null; writePath: string; } { const canonicalLstat = getLstatSync(canonicalPath); if (!canonicalLstat) { - return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; + return { content: null, identity: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; } let writePath = canonicalPath; @@ -230,8 +297,12 @@ function getCanonicalFile(canonicalPath: string): { }); } + // Identity first: a write landing between the two reads leaves us holding + // newer bytes than the identity describes, and publication fails closed. + const identity = readCanonicalIdentity(writePath); return { - content: fs.readFileSync(canonicalPath), + content: fs.readFileSync(writePath), + identity, mode: canonicalStats.mode & 0o777, mtimeMs: canonicalStats.mtimeMs, writePath, @@ -260,8 +331,6 @@ export function adoptDivergedFileContent( throw err; } - let canonicalClaimPath: string | null = null; - let canonicalWritePath: string | null = null; let divergencePreserved = false; try { const claimedStats = fs.lstatSync(claimPath); @@ -288,24 +357,12 @@ export function adoptDivergedFileContent( } const canonical = getCanonicalFile(canonicalPath); - let current = canonical.content; - let publishMode = canonical.mode; - if (current) { - canonicalWritePath = canonical.writePath; - canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; - fs.renameSync(canonical.writePath, canonicalClaimPath); - const currentStats = fs.statSync(canonicalClaimPath); - publishMode = currentStats.mode & 0o777; - current = fs.readFileSync(canonicalClaimPath); - if (diverged.equals(current)) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (canonical.content) { + if (diverged.equals(canonical.content)) { fs.unlinkSync(claimPath); return 'claimed'; } - if (claimedStats.mtimeMs <= currentStats.mtimeMs) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (claimedStats.mtimeMs <= (canonical.mtimeMs ?? 0)) { preserveClaim( claimPath, divergedPath, @@ -313,14 +370,17 @@ export function adoptDivergedFileContent( ); return 'claimed'; } + // Publish the pre-image before the canonical file is replaced, so an + // interruption mid-publication still leaves the old content recoverable. + publishSidecarNoReplace(`${canonicalPath}.bak-ccs-adopt`, canonical.content, canonical.mode); } + publishSidecarNoReplace( + `${divergedPath}.ccs-adopted-recovery`, + diverged, + claimedStats.mode & 0o777 + ); - if (canonicalClaimPath) { - publishBackupNoReplace(canonicalClaimPath, canonicalPath); - } - publishAdoptedRecoveryNoReplace(claimPath, divergedPath); - - atomicWriteFile(canonical.writePath, diverged, publishMode); + publishCanonicalContent(canonical.writePath, diverged, canonical.mode, canonical.identity); if (!fs.readFileSync(canonicalPath).equals(diverged)) { throw Object.assign( new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`), @@ -329,10 +389,6 @@ export function adoptDivergedFileContent( } ); } - if (canonicalClaimPath) { - fs.unlinkSync(canonicalClaimPath); - canonicalClaimPath = null; - } if (getLstatSync(divergedPath)) { preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); divergencePreserved = true; @@ -346,10 +402,6 @@ export function adoptDivergedFileContent( ); return 'claimed'; } catch (err) { - if (canonicalClaimPath && canonicalWritePath) { - restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath); - canonicalClaimPath = null; - } if (divergencePreserved) { throw err; } diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 0053a7da..a045c919 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -544,7 +544,18 @@ describe('SharedManager', () => { }); }); - it('preserves a canonical write that lands during no-replace publication', () => { + /** + * Replaces 'preserves a canonical write that lands during no-replace + * publication', which locked in the outcome of the CCS-4 incident. + * + * The intent it encoded - never clobber a writer that got to the canonical + * file first - is kept, but it is now enforced by the compare-and-swap + * guard instead of by an EEXIST from a no-replace link. The difference + * that matters: the canonical path is no longer emptied first, so only a + * genuinely concurrent write can land here, and it is preserved without + * costing the user the settings that were already there. + */ + it('refuses to publish when the canonical file changes before publication', () => { const manager = new SharedManager(); const instancePath = instanceDir('canonical-race'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -555,24 +566,132 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalLinkSync = fs.linkSync; - const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( - existingPath: fs.PathLike, - newPath: fs.PathLike + // Land the competing write while the publication temp file is being + // prepared, i.e. after the canonical bytes were read but before the + // compare-and-swap guard re-checks the inode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { writeJson(canonicalPath, { generation: 99 }); } - return originalLinkSync(existingPath, newPath); - }) as typeof fs.linkSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); - expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); - linkSpy.mockRestore(); + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Canonical file changed during adoption' + ); + openSpy.mockRestore(); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(divergedPath)).toEqual({ generation: 1 }); expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 }); + expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); + /** + * Model a foreign writer that shares ownership of the canonical + * settings.json: the instant the path is left without a file, it lands an + * empty-settings placeholder there. Claude Code does exactly this on + * startup, and a second concurrent `ccs` does the same through + * shared-dir-linker.ts:127. + * + * The writer reacts to the path becoming empty rather than to one specific + * call site, so it keeps modelling the race no matter which fs primitive + * the adopter uses to move the canonical file out of the way. + */ + function installForeignCanonicalWriter( + canonicalPath: string, + placeholder: string + ): { placeholderWrites: () => number; restore: () => void } { + let placeholderWrites = 0; + const claimEmptyCanonicalPath = (): void => { + if (fs.existsSync(canonicalPath)) return; + fs.writeFileSync(canonicalPath, placeholder, 'utf8'); + placeholderWrites++; + }; + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + claimEmptyCanonicalPath(); + }) as typeof fs.renameSync); + + const originalUnlinkSync = fs.unlinkSync; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + originalUnlinkSync(targetPath); + claimEmptyCanonicalPath(); + }) as typeof fs.unlinkSync); + + return { + placeholderWrites: () => placeholderWrites, + restore: () => { + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + }, + }; + } + + /** + * Reproduce the 2026-08-20 incident: adoption moves the canonical + * settings.json aside, a foreign writer fills the empty path, and the user + * ends up with neither the adopted nor the previous settings on the live + * path. + * + * The opposite outcome used to be pinned by 'preserves a canonical write + * that lands during no-replace publication'; that test was rewritten as + * 'refuses to publish when the canonical file changes before publication' + * once publication stopped emptying the canonical path. + */ + const foreignWriterCases: ReadonlyArray<{ writer: string; placeholder: string }> = [ + // Claude Code starts, finds no settings file and writes empty settings. + { writer: 'Claude Code', placeholder: '{}\n' }, + // A second concurrent `ccs` provisions the same placeholder without the + // trailing newline (shared-dir-linker.ts:127). + { writer: 'a concurrent ccs run', placeholder: JSON.stringify({}, null, 2) }, + ]; + + for (const { writer, placeholder } of foreignWriterCases) { + it(`keeps live settings when ${writer} fills the canonical path during adoption`, () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + const previousSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)'] }, + }; + const divergedSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)', 'Bash(git diff:*)'] }, + }; + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(canonicalPath, previousSettings); + writeJson(sharedSettingsPath, divergedSettings); + setMtime(sharedSettingsPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const foreignWriter = installForeignCanonicalWriter(canonicalPath, placeholder); + try { + manager.ensureSharedDirectories(); + } catch { + // Losing the race may abort reconciliation; the user's live settings + // must survive either way. + } finally { + foreignWriter.restore(); + } + + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.readFileSync(canonicalPath, 'utf8')).not.toBe(placeholder); + expect([divergedSettings, previousSettings]).toContainEqual(readJson(canonicalPath)); + }); + } + it('keeps adopted bytes recoverable when canonical changes after verification', () => { const manager = new SharedManager(); const instancePath = instanceDir('late-canonical-writer'); @@ -584,10 +703,12 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + // The diverged claim is dropped only after publication was verified, so + // a write injected there lands strictly after adoption completed. const originalUnlinkSync = fs.unlinkSync; let injected = false; const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + if (!injected && String(targetPath).includes('.ccs-adopt-claim-')) { injected = true; writeJson(canonicalPath, { generation: 99 }); } @@ -596,6 +717,7 @@ describe('SharedManager', () => { manager.linkSharedDirectories(instancePath); unlinkSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); @@ -611,20 +733,30 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalUnlinkSync = fs.unlinkSync; + // Recreate the managed source right after the canonical file was + // replaced, while the adopter is still cleaning up. + const originalRenameSync = fs.renameSync; let injected = false; - const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + if ( + !injected && + String(newPath) === canonicalPath && + String(oldPath).startsWith(`${canonicalPath}.ccs-write-`) + ) { injected = true; writeJson(divergedPath, { generation: 2 }); } - return originalUnlinkSync(targetPath); - }) as typeof fs.unlinkSync); + }) as typeof fs.renameSync); expect(() => manager.linkSharedDirectories(instancePath)).toThrow( 'Concurrent replacement detected' ); - unlinkSpy.mockRestore(); + renameSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(divergedPath)).toEqual({ generation: 2 }); }); @@ -694,7 +826,7 @@ describe('SharedManager', () => { } }); - it('uses the mode of the canonical inode actually claimed for publication', () => { + it('publishes with the mode the canonical inode carries at publication time', () => { const manager = new SharedManager(); const instancePath = instanceDir('concurrent-mode'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -706,23 +838,24 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalRenameSync = fs.renameSync; - const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( - oldPath: fs.PathLike, - newPath: fs.PathLike + // A chmod between reading the canonical file and publishing it leaves + // the content untouched, so publication proceeds with the newer mode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if ( - String(oldPath) === canonicalPath && - String(newPath).includes('.ccs-canonical-claim-') - ) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { fs.chmodSync(canonicalPath, 0o664); } - return originalRenameSync(oldPath, newPath); - }) as typeof fs.renameSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); manager.linkSharedDirectories(instancePath); - renameSpy.mockRestore(); + openSpy.mockRestore(); expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664); + expect(readJson(canonicalPath)).toEqual({ generation: 1 }); }); it('recovers an interrupted canonical claim before provisioning defaults', () => {