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:
@@ -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:^",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -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` 或流式形态。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+12
@@ -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':
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user