feat(util): extract dsh-atomic-write and migrate settings-local writes

writeFileAtomic: exclusive-create random-suffix temp + rename carrying the
caller-stated mode; settings-local persistSection now consumes it. The
credentials-local store shares it next.
This commit is contained in:
Yichen Jiang
2026-07-29 12:59:41 +08:00
parent 42de60347a
commit ba37180946
13 changed files with 281 additions and 16 deletions
@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -38,6 +39,7 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
+5 -16
View File
@@ -8,10 +8,10 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { randomBytes } from 'node:crypto'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -137,19 +137,8 @@ export class SettingsLocal extends Settings {
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
await mkdir(dirname(this.spec.filename), { recursive: true })
// Exclusive-create (`wx`) a random-suffix sibling: the open refuses to
// follow any planted symlink at a guessable temp path, and the fresh inode
// carries owner-only permissions that survive the rename — a document that
// may hold personal values is never world-readable and never a symlink.
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
await rename(temp, this.spec.filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
// 0600: a document that may hold personal values is never world-readable.
await writeFileAtomic(this.spec.filename, output, { mode: 0o600 })
this.text = output
}
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},
+30
View File
@@ -0,0 +1,30 @@
# dsh-atomic-write
English | [中文](README.zh.md)
Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`).
## Surface
```ts
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
```
One export. The contract, in the order failures would exploit it:
- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path.
- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode).
- **`rename` replaces a symlinked target itself**, never writing through to its referent.
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
## Model Experience
None, as this is a pure filesystem primitive; nothing here reaches a model request.
## Known Limitations and Deferred Work
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
- **String content only** — no `Buffer` or stream form until a consumer needs one.
+30
View File
@@ -0,0 +1,30 @@
# dsh-atomic-write
[English](README.md) | 中文
零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。
## 接口面
```ts
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
```
仅一个导出。契约按攻击面利用顺序列出:
- **独占创建临时文件**`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。
- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。
- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。
## Model Experience
None, as this is a pure filesystem primitive; nothing here reaches a model request.
## Known Limitations and Deferred Work
- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。
- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-atomic-write",
"description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Zero-dependency atomic file replacement. `writeFileAtomic` writes a
* random-suffix sibling with exclusive create and the caller's permission
* bits, then renames it over the target, so readers observe either the old or
* the new complete content and a replaced file ends up with exactly the
* stated mode.
* @module @deepseek-ai/dsh-atomic-write
*/
import { randomBytes } from 'node:crypto'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/**
* Filesystem options for {@link writeFileAtomic}; `mode` is required so the
* permission decision stays visible at every call site.
*/
export interface WriteFileAtomicOptions {
/**
* Permission bits stamped on the fresh temp inode and carried through the
* rename (subject to the process umask, like every fresh inode).
*/
mode: number
}
/**
* Replace `filename` with `content` in one atomic step, creating parent
* directories. The content is first written to a random-suffix sibling opened
* with exclusive create (`wx`): the open refuses to follow a symlink planted
* at the temp path, and the fresh inode carries `options.mode` through the
* rename, so replacing a wider-permission file narrows it without a chmod
* race. The rename also replaces a symlinked target itself instead of writing
* through to its referent, and the same-directory sibling keeps the rename on
* one filesystem. On any failure the temp file is removed and the failure
* rethrown. Crash durability (fsync) is out of scope.
* @param filename - final path receiving the content.
* @param content - complete next file content.
* @param options - permission bits for the replacement inode.
*/
export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
await mkdir(dirname(filename), { recursive: true })
const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
await rename(temp, filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`.
* @module @deepseek-ai/dsh-atomic-write/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write'
/** Cordis companion plugin name. */
export const name = 'atomic-write-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime
* data; its replacement contract is enforced by unit tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,48 @@
import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { writeFileAtomic } from '../src/index.ts'
async function scratch(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
}
describe('writeFileAtomic', () => {
it('creates the file and its parents with exactly the stated mode', async () => {
const dir = await scratch()
const target = join(dir, 'nested', 'deep', 'doc.yaml')
await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
expect(await readFile(target, 'utf8')).toBe('a: 1\n')
expect((await stat(target)).mode & 0o777).toBe(0o600)
})
it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
const dir = await scratch()
const target = join(dir, 'doc.yaml')
await writeFile(target, 'old', { mode: 0o644 })
await writeFileAtomic(target, 'new', { mode: 0o600 })
expect(await readFile(target, 'utf8')).toBe('new')
expect((await stat(target)).mode & 0o777).toBe(0o600)
})
it('replaces a symlinked target itself without writing through to the referent', async () => {
const dir = await scratch()
const victim = join(dir, 'victim')
await writeFile(victim, 'victim-content')
const target = join(dir, 'doc.yaml')
await symlink(victim, target)
await writeFileAtomic(target, 'replaced', { mode: 0o600 })
expect((await lstat(target)).isSymbolicLink()).toBe(false)
expect(await readFile(target, 'utf8')).toBe('replaced')
expect(await readFile(victim, 'utf8')).toBe('victim-content')
})
it('leaves no temp sibling and rethrows when the rename fails', async () => {
const dir = await scratch()
const target = join(dir, 'occupied')
await mkdir(target)
await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
})
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AtomicWriteInvariant from '../src/invariant.ts'
describe('atomic-write invariant companion', () => {
it('registers its explained empty runtime invariant', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(AtomicWriteInvariant)
expect(() => {
ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {})
}).toThrow(/already registered/)
await fiber.dispose()
await ctx.fiber.dispose()
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}
+12
View File
@@ -3895,6 +3895,9 @@ importers:
specifier: ^2.9.0
version: 2.9.0
devDependencies:
'@deepseek-ai/dsh-atomic-write':
specifier: workspace:^
version: link:../../util/atomic-write
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -4985,6 +4988,15 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/util/atomic-write:
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/util/brand:
devDependencies:
'@deepseek-ai/dsh-invariants':
+1
View File
@@ -49,6 +49,7 @@
{ "path": "./packages/util/paths" },
{ "path": "./packages/util/timeout" },
{ "path": "./packages/util/retention" },
{ "path": "./packages/util/atomic-write" },
{ "path": "./packages/llm/llm" },
{ "path": "./packages/llm/token-meter" },
{ "path": "./packages/core/session" },