Selaa lähdekoodia

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.
Yichen Jiang 1 kuukausi sitten
vanhempi
sitoutus
ba37180946

+ 2 - 0
packages/settings/settings-local/package.json

@@ -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
packages/settings/settings-local/src/index.ts

@@ -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
   }
 

+ 3 - 0
packages/settings/settings-local/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../../vendor/schemastery"
     },
+    {
+      "path": "../../util/atomic-write"
+    },
     {
       "path": "../../util/paths"
     },

+ 30 - 0
packages/util/atomic-write/README.md

@@ -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 - 0
packages/util/atomic-write/README.zh.md

@@ -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 - 0
packages/util/atomic-write/package.json

@@ -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 - 0
packages/util/atomic-write/src/index.ts

@@ -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
+  }
+}

+ 30 - 0
packages/util/atomic-write/src/invariant.ts

@@ -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 */

+ 48 - 0
packages/util/atomic-write/tests/atomic-write.spec.ts

@@ -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([])
+  })
+})

+ 18 - 0
packages/util/atomic-write/tests/invariant.spec.ts

@@ -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 - 0
packages/util/atomic-write/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 12 - 0
pnpm-lock.yaml

@@ -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 - 0
tsconfig.host.json

@@ -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" },