diff --git a/src/config-writer.spec.ts b/src/config-writer.spec.ts index 1c5c1ff..2e6710a 100644 --- a/src/config-writer.spec.ts +++ b/src/config-writer.spec.ts @@ -1,4 +1,4 @@ -import { lstatSync, mkdtempSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdtempSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, it, expect, beforeEach } from 'vitest' @@ -333,4 +333,48 @@ describe('writeConfig', () => { expect(mcp).toHaveLength(3) expect(mcp.map((e: { serverName: string }) => e.serverName)).toEqual(['server_a', 'server_b', 'server_c']) }) + + it('preserves malformed mcp-config.json and does not write mdm-config.json', () => { + const mcpPath = join(outputDir, 'mcp-config.json') + const original = '{"serverName":' + writeFileSync(mcpPath, original) + + expect(() => + writeConfig({ + serverName: 'glean_default', + serverUrl: 'https://example.com/mcp/default', + autoUpdate: false, + binaryUrlPrefix: 'https://example.com/binaries', + outputDir, + }), + ).toThrow() + + expect(readFileSync(mcpPath, 'utf-8')).toBe(original) + expect(existsSync(join(outputDir, 'mdm-config.json'))).toBe(false) + }) + + it('preserves unknown fields when appending an MCP server', () => { + const mcpPath = join(outputDir, 'mcp-config.json') + writeFileSync( + mcpPath, + JSON.stringify([ + { + serverName: 'existing', + url: 'https://existing.example.com/mcp', + futureField: { enabled: true }, + }, + ]), + ) + + writeConfig({ + serverName: 'glean_default', + serverUrl: 'https://example.com/mcp/default', + autoUpdate: false, + binaryUrlPrefix: 'https://example.com/binaries', + outputDir, + }) + + const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8')) + expect(mcp[0].futureField).toEqual({ enabled: true }) + }) }) diff --git a/src/config-writer.ts b/src/config-writer.ts index bcc65e8..9af5cf6 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -1,8 +1,9 @@ -import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { join } from 'node:path' import { McpConfigSchema, type McpServerEntry, MdmConfigSchema } from './config.js' import { log } from './logger.js' +import { atomicWriteFile, readTextFileIfExists, resolveWritePath, withFileLock } from './managed-file.js' import { getDefaultConfigDir } from './platform.js' export interface WriteConfigOptions { @@ -15,24 +16,9 @@ export interface WriteConfigOptions { outputDir?: string } -function resolveWritePath(filePath: string): string { - try { - return realpathSync(filePath) - } catch { - return filePath - } -} - function readExistingMcpEntries(filePath: string): McpServerEntry[] { - let raw: string - try { - raw = readFileSync(filePath, 'utf-8') - } catch (err: any) { - if (err.code === 'ENOENT') { - return [] - } - throw err - } + const raw = readTextFileIfExists(filePath) + if (raw === undefined) return [] const json = JSON.parse(raw) return McpConfigSchema.parse(json) } @@ -53,28 +39,28 @@ export function writeConfig(options: WriteConfigOptions): void { const parsedMdm = MdmConfigSchema.parse(mdmData) const mcpPath = join(outputDir, 'mcp-config.json') - const existingEntries = readExistingMcpEntries(mcpPath) - const nameMatch = existingEntries.find((e) => e.serverName === newEntry.serverName) - const urlMatch = existingEntries.find((e) => e.url === newEntry.url) + const mcpWritePath = resolveWritePath(mcpPath) + withFileLock(mcpWritePath, () => { + const existingEntries = readExistingMcpEntries(mcpWritePath) + const nameMatch = existingEntries.find((e) => e.serverName === newEntry.serverName) + const urlMatch = existingEntries.find((e) => e.url === newEntry.url) - if (nameMatch) { - log.info(`Skipped ${mcpPath} (entry "${newEntry.serverName}" already exists)`) - } else if (urlMatch) { - log.info(`Skipped ${mcpPath} (URL "${newEntry.url}" already configured under "${urlMatch.serverName}")`) - } else { - const merged = [...existingEntries, newEntry] - const mcpWritePath = resolveWritePath(mcpPath) - const mcpTmp = `${mcpWritePath}.tmp` - writeFileSync(mcpTmp, JSON.stringify(merged, null, 2) + '\n') - renameSync(mcpTmp, mcpWritePath) - log.info(`Added entry "${newEntry.serverName}" to ${mcpPath}`) - } + if (nameMatch) { + log.info(`Skipped ${mcpPath} (entry "${newEntry.serverName}" already exists)`) + } else if (urlMatch) { + log.info(`Skipped ${mcpPath} (URL "${newEntry.url}" already configured under "${urlMatch.serverName}")`) + } else { + const merged = [...existingEntries, newEntry] + atomicWriteFile(mcpWritePath, JSON.stringify(merged, null, 2) + '\n') + log.info(`Added entry "${newEntry.serverName}" to ${mcpPath}`) + } + }) const mdmPath = join(outputDir, 'mdm-config.json') const mdmWritePath = resolveWritePath(mdmPath) - const mdmTmp = `${mdmWritePath}.tmp` - writeFileSync(mdmTmp, JSON.stringify(parsedMdm, null, 2) + '\n') - renameSync(mdmTmp, mdmWritePath) + withFileLock(mdmWritePath, () => { + atomicWriteFile(mdmWritePath, JSON.stringify(parsedMdm, null, 2) + '\n') + }) log.info(`Wrote ${mdmPath}`) } diff --git a/src/config.spec.ts b/src/config.spec.ts index 6c55128..b2e4f56 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -53,6 +53,7 @@ describe('McpConfigSchema', () => { }) expect(result.success).toBe(true) + if (result.success) expect(result.data[0].someNewField).toBe('future-value') }) }) @@ -76,6 +77,7 @@ describe('MdmConfigSchema', () => { }) expect(result.success).toBe(true) + if (result.success) expect(result.data.someNewField).toBe('future-value') }) describe('autoUpdate', () => { diff --git a/src/config.ts b/src/config.ts index 156ef05..b4069b2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ const SEMVER_PATTERN = /^v?\d+\.\d+\.\d+$/ const McpServerEntrySchema = z.object({ serverName: z.string().min(1), url: z.string().min(1), -}) +}).passthrough() export const McpConfigSchema = z .union([z.array(McpServerEntrySchema).min(1), McpServerEntrySchema]) @@ -28,6 +28,7 @@ export const MdmConfigSchema = z z.string().url(), ), }) + .passthrough() .refine((data) => !data.autoUpdate || data.versionUrl !== undefined, { message: 'versionUrl is required when autoUpdate is true', path: ['versionUrl'], diff --git a/src/hosts/json-configurator.ts b/src/hosts/json-configurator.ts index ac4501c..5a3a95b 100644 --- a/src/hosts/json-configurator.ts +++ b/src/hosts/json-configurator.ts @@ -1,40 +1,9 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' - -import { log } from '../logger.js' - -import { isPlainObject, resolveWritePath, withoutDuplicateUrls } from './utils.js' - -export interface ConfigureFileOptions { - configToMerge: Record - filePath: string -} +import { type ConfigureFileOptions, configureManagedFile } from './managed-config.js' export function configureJsonFile(options: ConfigureFileOptions): void { - const { configToMerge, filePath } = options - - let existing: Record = {} - try { - existing = JSON.parse(readFileSync(filePath, 'utf-8')) - } catch { - // File doesn't exist or isn't valid JSON — start fresh - } - - for (const [key, value] of Object.entries(configToMerge)) { - if (isPlainObject(value)) { - const existingSection = isPlainObject(existing[key]) ? existing[key] : {} - const filtered = withoutDuplicateUrls(existingSection, value) - existing[key] = { ...existingSection, ...filtered } - } else { - existing[key] = value - } - } - - const writePath = resolveWritePath(filePath) - mkdirSync(dirname(writePath), { recursive: true }) - const tmpPath = `${writePath}.tmp` - writeFileSync(tmpPath, JSON.stringify(existing, null, 2) + '\n') - renameSync(tmpPath, writePath) - - log.info(`Configured JSON: ${filePath}`) + configureManagedFile(options, { + format: 'JSON', + parse: JSON.parse, + serialize: (config) => JSON.stringify(config, null, 2) + '\n', + }) } diff --git a/src/hosts/managed-config.spec.ts b/src/hosts/managed-config.spec.ts new file mode 100644 index 0000000..1d5b358 --- /dev/null +++ b/src/hosts/managed-config.spec.ts @@ -0,0 +1,78 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, describe, expect, it } from 'vitest' + +import { configureJsonFile } from './json-configurator' +import { configureTomlFile } from './toml-configurator' +import { configureYamlFile } from './yaml-configurator' + +const configToMerge = { + mcpServers: { + glean_default: { + type: 'http', + url: 'https://example-be.glean.com/mcp/default', + }, + }, +} + +const formats = [ + { + configure: configureJsonFile, + extension: 'json', + format: 'JSON', + invalidContent: '{"mcpServers":', + nonObjectContent: '[]', + }, + { + configure: configureTomlFile, + extension: 'toml', + format: 'TOML', + invalidContent: 'mcp_servers = [', + }, + { + configure: configureYamlFile, + extension: 'yaml', + format: 'YAML', + invalidContent: 'mcpServers: [unterminated', + nonObjectContent: '- item\n', + }, +] + +describe.each(formats)('$format managed configuration', (formatCase) => { + let tempDir: string + let filePath: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), `managed-${formatCase.extension}-test-`)) + filePath = join(tempDir, `config.${formatCase.extension}`) + }) + + it('preserves malformed files byte-for-byte', () => { + writeFileSync(filePath, formatCase.invalidContent) + + expect(() => formatCase.configure({ configToMerge, filePath })).toThrow( + `Cannot update invalid ${formatCase.format} configuration`, + ) + + expect(readFileSync(filePath, 'utf-8')).toBe(formatCase.invalidContent) + expect(readdirSync(tempDir)).toEqual([`config.${formatCase.extension}`]) + }) + + it('does not treat read errors as missing files', () => { + mkdirSync(filePath) + + expect(() => formatCase.configure({ configToMerge, filePath })).toThrow() + expect(readdirSync(tempDir)).toEqual([`config.${formatCase.extension}`]) + }) + + if (formatCase.nonObjectContent) { + it('preserves files whose root value is not an object', () => { + writeFileSync(filePath, formatCase.nonObjectContent) + + expect(() => formatCase.configure({ configToMerge, filePath })).toThrow(/root value must be an object/) + expect(readFileSync(filePath, 'utf-8')).toBe(formatCase.nonObjectContent) + }) + } +}) diff --git a/src/hosts/managed-config.ts b/src/hosts/managed-config.ts new file mode 100644 index 0000000..098ca5c --- /dev/null +++ b/src/hosts/managed-config.ts @@ -0,0 +1,69 @@ +import { log } from '../logger.js' +import { atomicWriteFile, readTextFileIfExists, resolveWritePath, withFileLock } from '../managed-file.js' + +import { isPlainObject, withoutDuplicateUrls } from './utils.js' + +export interface ConfigureFileOptions { + configToMerge: Record + filePath: string +} + +export interface ManagedConfigCodec { + format: string + parse: (content: string) => unknown + serialize: (config: Record) => string +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function mergeManagedConfig( + existing: Record, + configToMerge: Record, +): Record { + const merged = { ...existing } + + for (const [key, value] of Object.entries(configToMerge)) { + if (isPlainObject(value)) { + const existingSection = isPlainObject(merged[key]) ? merged[key] : {} + const filtered = withoutDuplicateUrls(existingSection, value) + merged[key] = { ...existingSection, ...filtered } + } else { + merged[key] = value + } + } + + return merged +} + +export function configureManagedFile(options: ConfigureFileOptions, codec: ManagedConfigCodec): void { + const { configToMerge, filePath } = options + const writePath = resolveWritePath(filePath) + + withFileLock(writePath, () => { + const content = readTextFileIfExists(writePath) + let existing: Record = {} + + if (content !== undefined) { + let parsed: unknown + try { + parsed = codec.parse(content) + } catch (error) { + throw new Error(`Cannot update invalid ${codec.format} configuration at ${filePath}: ${describeError(error)}`, { + cause: error, + }) + } + + if (!isPlainObject(parsed)) { + throw new Error(`Cannot update ${codec.format} configuration at ${filePath}: root value must be an object`) + } + existing = parsed + } + + const merged = mergeManagedConfig(existing, configToMerge) + atomicWriteFile(writePath, codec.serialize(merged)) + }) + + log.info(`Configured ${codec.format}: ${filePath}`) +} diff --git a/src/hosts/toml-configurator.ts b/src/hosts/toml-configurator.ts index c64f236..faf695b 100644 --- a/src/hosts/toml-configurator.ts +++ b/src/hosts/toml-configurator.ts @@ -1,42 +1,11 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' - import * as TOML from 'smol-toml' -import { log } from '../logger.js' - -import { isPlainObject, resolveWritePath, withoutDuplicateUrls } from './utils.js' - -export interface ConfigureFileOptions { - configToMerge: Record - filePath: string -} +import { type ConfigureFileOptions, configureManagedFile } from './managed-config.js' export function configureTomlFile(options: ConfigureFileOptions): void { - const { configToMerge, filePath } = options - - let existing: Record = {} - try { - existing = TOML.parse(readFileSync(filePath, 'utf-8')) as Record - } catch { - // Start fresh - } - - for (const [key, value] of Object.entries(configToMerge)) { - if (isPlainObject(value)) { - const existingSection = isPlainObject(existing[key]) ? existing[key] : {} - const filtered = withoutDuplicateUrls(existingSection, value) - existing[key] = { ...existingSection, ...filtered } - } else { - existing[key] = value - } - } - - const writePath = resolveWritePath(filePath) - mkdirSync(dirname(writePath), { recursive: true }) - const tmpPath = `${writePath}.tmp` - writeFileSync(tmpPath, TOML.stringify(existing)) - renameSync(tmpPath, writePath) - - log.info(`Configured TOML: ${filePath}`) + configureManagedFile(options, { + format: 'TOML', + parse: TOML.parse, + serialize: TOML.stringify, + }) } diff --git a/src/hosts/utils.ts b/src/hosts/utils.ts index 22a71d2..f37c712 100644 --- a/src/hosts/utils.ts +++ b/src/hosts/utils.ts @@ -1,17 +1,7 @@ -import { realpathSync } from 'node:fs' - import { createGleanRegistry } from '@gleanwork/mcp-config-glean' import { log } from '../logger.js' -export function resolveWritePath(filePath: string): string { - try { - return realpathSync(filePath) - } catch { - return filePath - } -} - export function isPlainObject(val: unknown): val is Record { return typeof val === 'object' && val !== null && !Array.isArray(val) } diff --git a/src/hosts/yaml-configurator.ts b/src/hosts/yaml-configurator.ts index 17eee53..2d475a5 100644 --- a/src/hosts/yaml-configurator.ts +++ b/src/hosts/yaml-configurator.ts @@ -1,43 +1,11 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' - import YAML from 'yaml' -import { log } from '../logger.js' - -import { isPlainObject, resolveWritePath, withoutDuplicateUrls } from './utils.js' - -export interface ConfigureFileOptions { - configToMerge: Record - filePath: string -} +import { type ConfigureFileOptions, configureManagedFile } from './managed-config.js' export function configureYamlFile(options: ConfigureFileOptions): void { - const { configToMerge, filePath } = options - - let existing: Record = {} - try { - const content = readFileSync(filePath, 'utf-8') - existing = (YAML.parse(content) as Record) ?? {} - } catch { - // Start fresh - } - - for (const [key, value] of Object.entries(configToMerge)) { - if (isPlainObject(value)) { - const existingSection = isPlainObject(existing[key]) ? existing[key] : {} - const filtered = withoutDuplicateUrls(existingSection, value) - existing[key] = { ...existingSection, ...filtered } - } else { - existing[key] = value - } - } - - const writePath = resolveWritePath(filePath) - mkdirSync(dirname(writePath), { recursive: true }) - const tmpPath = `${writePath}.tmp` - writeFileSync(tmpPath, YAML.stringify(existing)) - renameSync(tmpPath, writePath) - - log.info(`Configured YAML: ${filePath}`) + configureManagedFile(options, { + format: 'YAML', + parse: (content) => YAML.parse(content) ?? {}, + serialize: YAML.stringify, + }) } diff --git a/src/managed-file.spec.ts b/src/managed-file.spec.ts new file mode 100644 index 0000000..f09b0ee --- /dev/null +++ b/src/managed-file.spec.ts @@ -0,0 +1,77 @@ +import { + chmodSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, describe, expect, it } from 'vitest' + +import { atomicWriteFile, resolveWritePath, withFileLock } from './managed-file' + +describe('managed file operations', () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'managed-file-test-')) + }) + + it('creates new files with owner-only permissions', () => { + const filePath = join(tempDir, 'config.json') + + atomicWriteFile(filePath, '{}\n') + + expect(readFileSync(filePath, 'utf-8')).toBe('{}\n') + if (process.platform !== 'win32') { + expect(statSync(filePath).mode & 0o777).toBe(0o600) + } + }) + + it('preserves the mode of an existing file', () => { + if (process.platform === 'win32') return + + const filePath = join(tempDir, 'config.json') + writeFileSync(filePath, 'old') + chmodSync(filePath, 0o640) + + atomicWriteFile(filePath, 'new') + + expect(readFileSync(filePath, 'utf-8')).toBe('new') + expect(statSync(filePath).mode & 0o777).toBe(0o640) + }) + + it('uses unique temporary files and cleans them after replacement', () => { + const filePath = join(tempDir, 'config.json') + + atomicWriteFile(filePath, 'first') + atomicWriteFile(filePath, 'second') + + expect(readFileSync(filePath, 'utf-8')).toBe('second') + expect(readdirSync(tempDir)).toEqual(['config.json']) + }) + + it('rejects overlapping mutations and releases the lock afterward', () => { + const filePath = join(tempDir, 'config.json') + + withFileLock(filePath, () => { + expect(() => withFileLock(filePath, () => undefined)).toThrow(/already being modified/) + }) + + expect(() => withFileLock(filePath, () => undefined)).not.toThrow() + expect(readdirSync(tempDir)).toEqual([]) + }) + + it('rejects dangling symlinks instead of replacing them', () => { + const filePath = join(tempDir, 'config.json') + symlinkSync(join(tempDir, 'missing.json'), filePath) + + expect(() => resolveWritePath(filePath)).toThrow() + expect(lstatSync(filePath).isSymbolicLink()).toBe(true) + }) +}) diff --git a/src/managed-file.ts b/src/managed-file.ts new file mode 100644 index 0000000..8865569 --- /dev/null +++ b/src/managed-file.ts @@ -0,0 +1,225 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + closeSync, + fchmodSync, + fchownSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + statSync, + type Stats, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +const DEFAULT_FILE_MODE = 0o600 +const INVALID_LOCK_STALE_MS = 10 * 60 * 1000 + +interface LockData { + createdAt: number + pid: number + token: string +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +export function resolveWritePath(filePath: string): string { + let file: ReturnType + try { + file = lstatSync(filePath) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return filePath + } + throw error + } + return file.isSymbolicLink() ? realpathSync(filePath) : filePath +} + +export function readTextFileIfExists(filePath: string): string | undefined { + try { + return readFileSync(filePath, 'utf-8') + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return undefined + } + throw error + } +} + +function readLock(lockPath: string): LockData | undefined { + try { + const value = JSON.parse(readFileSync(lockPath, 'utf-8')) as Partial + if ( + typeof value.createdAt === 'number' && + typeof value.pid === 'number' && + typeof value.token === 'string' + ) { + return value as LockData + } + } catch { + return undefined + } + return undefined +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !hasErrorCode(error, 'ESRCH') + } +} + +function removeStaleLock(lockPath: string): boolean { + const lock = readLock(lockPath) + if (lock) { + if (isProcessAlive(lock.pid)) { + return false + } + } else { + try { + const age = Date.now() - statSync(lockPath).mtimeMs + if (age < INVALID_LOCK_STALE_MS) { + return false + } + } catch (error) { + return hasErrorCode(error, 'ENOENT') + } + } + + try { + unlinkSync(lockPath) + return true + } catch (error) { + return hasErrorCode(error, 'ENOENT') + } +} + +export function withFileLock(filePath: string, operation: () => T): T { + const directory = dirname(filePath) + mkdirSync(directory, { recursive: true }) + + const lockPath = join(directory, `.${basename(filePath)}.glean-mdm.lock`) + const lock: LockData = { + createdAt: Date.now(), + pid: process.pid, + token: randomUUID(), + } + + let lockFd: number + try { + lockFd = openSync(lockPath, 'wx', DEFAULT_FILE_MODE) + } catch (error) { + if (!hasErrorCode(error, 'EEXIST') || !removeStaleLock(lockPath)) { + throw new Error(`Configuration file is already being modified: ${filePath}`, { cause: error }) + } + lockFd = openSync(lockPath, 'wx', DEFAULT_FILE_MODE) + } + + try { + try { + writeFileSync(lockFd, JSON.stringify(lock)) + fsyncSync(lockFd) + } catch (error) { + try { + unlinkSync(lockPath) + } catch { + // The original initialization failure is more useful to the caller. + } + throw error + } + } finally { + closeSync(lockFd) + } + + try { + return operation() + } finally { + if (readLock(lockPath)?.token === lock.token) { + try { + unlinkSync(lockPath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + } + } + } +} + +function preserveWindowsAcl(sourcePath: string, targetPath: string): void { + const escape = (value: string) => value.replace(/'/g, "''") + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$acl = Get-Acl -LiteralPath '${escape(sourcePath)}' -ErrorAction Stop; Set-Acl -LiteralPath '${escape(targetPath)}' -AclObject $acl -ErrorAction Stop`, + ], + { stdio: 'pipe', timeout: 30_000 }, + ) +} + +export function atomicWriteFile(filePath: string, content: string): void { + let existing: Stats | undefined + try { + existing = statSync(filePath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + } + + const directory = dirname(filePath) + mkdirSync(directory, { recursive: true }) + + const mode = existing ? existing.mode & 0o7777 : DEFAULT_FILE_MODE + const tempPath = join(directory, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`) + let tempFd: number | undefined + + try { + tempFd = openSync(tempPath, 'wx', mode) + writeFileSync(tempFd, content, 'utf-8') + + if (existing && process.platform !== 'win32') { + const currentUid = process.getuid?.() + const currentGid = process.getgid?.() + if (existing.uid !== currentUid || existing.gid !== currentGid) { + fchownSync(tempFd, existing.uid, existing.gid) + } + fchmodSync(tempFd, mode) + } + + fsyncSync(tempFd) + closeSync(tempFd) + tempFd = undefined + + if (existing && process.platform === 'win32') { + preserveWindowsAcl(filePath, tempPath) + } + + renameSync(tempPath, filePath) + } finally { + if (tempFd !== undefined) { + closeSync(tempFd) + } + try { + unlinkSync(tempPath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + } + } +}