Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup
# Conflicts: # packages/sandbox/sandbox-windows-acl/src/index.ts
This commit is contained in:
@@ -26,13 +26,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -51,14 +51,13 @@ export interface DshProfileManifest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `dsh`-owned manifest section of a package.json. The nested key names
|
||||
* the manifest kind: a bundle package declares `bundle`, a profile directory
|
||||
* declares `profile`; nothing declares both.
|
||||
* The profile-launcher slice of the `dsh`-owned package.json section. A
|
||||
* manifest may declare both roles; other consumers own additional keys.
|
||||
*/
|
||||
export interface DshManifestSection {
|
||||
/** Present on bundle packages only. */
|
||||
/** Bundle metadata consumed by the profile launcher. */
|
||||
bundle?: DshBundleManifest
|
||||
/** Present on profile manifests only. */
|
||||
/** Profile metadata consumed by the profile launcher. */
|
||||
profile?: DshProfileManifest
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
# `dsh.client` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: code-runtime
|
||||
@@ -101,9 +101,9 @@
|
||||
printUrl: true
|
||||
surfaceContext: true
|
||||
|
||||
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
|
||||
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
|
||||
|
||||
# Dual-face: node half scans this very tree for dshClient rows, composes
|
||||
# Dual-face: node half scans this very tree for dsh.client rows, composes
|
||||
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
|
||||
# module table the shell kernel constructs before cordis exists (adopted
|
||||
# as a plugin entry by the kernel, never fetched).
|
||||
|
||||
@@ -91,9 +91,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace
|
||||
README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f
|
||||
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
|
||||
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0
|
||||
|
||||
@@ -8,7 +8,7 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -44,7 +44,7 @@ declare module 'cordis' {
|
||||
* One composed client entry pushed by the host (a graph row). Wire
|
||||
* single source: the host node half (package root) produces this same shape.
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* metadata (the authoritative edges live in each package's `dsh.client`
|
||||
* declaration and reach fibers through entry creation).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* Node half of the client module system (`dsh.client` dual-face package): scans
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
|
||||
* map, taps the index render to inject the boot manifest, and provides the
|
||||
@@ -43,7 +43,7 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration fields, validated one by one after reading the file. */
|
||||
/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
@@ -51,7 +51,7 @@ interface DshClientDeclaration {
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
|
||||
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
|
||||
interface PkgMeta {
|
||||
clientPath: string
|
||||
inject?: string[]
|
||||
@@ -105,21 +105,21 @@ interface WebPluginRecord {
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
|
||||
}
|
||||
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
|
||||
}
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
@@ -175,7 +175,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental dshClient scan + wire composition
|
||||
* The web plugin table service: incremental `dsh.client` scan + wire composition
|
||||
* + bundle route + index tap. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
@@ -186,7 +186,7 @@ export class ClientModuleHostService extends Service {
|
||||
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
|
||||
// subpath rows — or a package without a web dshClient declaration) are
|
||||
// subpath rows — or a package without a web `dsh.client` declaration) are
|
||||
// cached as null and never expire: plugin-set changes take effect on restart.
|
||||
private readonly pkgMeta = new Map<string, PkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
@@ -342,14 +342,18 @@ export class ClientModuleHostService extends Service {
|
||||
return null
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(pkgName, pkg.dshClient)
|
||||
const dsh = pkg.dsh
|
||||
const decl = parseDshClient(
|
||||
pkgName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
return null
|
||||
}
|
||||
const clientRel = clientExportOf(pkgName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
|
||||
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
|
||||
@@ -17,8 +17,11 @@ afterEach(() => {
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Create a resolvable dshClient package whose client export points at the returned path. */
|
||||
function writePackage(packageName: string): string {
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
|
||||
): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
|
||||
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
|
||||
const clientPath = join(pkgRoot, 'lib', 'client.js')
|
||||
@@ -29,7 +32,7 @@ function writePackage(packageName: string): string {
|
||||
'./client': './lib/client.js',
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
dshClient: { platform: 'web' },
|
||||
...metadata,
|
||||
}))
|
||||
return clientPath
|
||||
}
|
||||
@@ -66,6 +69,20 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
it('allows sibling dsh roles', () => {
|
||||
const currentName = '@fixture/current-client-field'
|
||||
const clientPath = writePackage(currentName, {
|
||||
dsh: {
|
||||
bundle: { patch: './cordis.patch.yml' },
|
||||
client: { platform: 'web' },
|
||||
profile: { bundles: [] },
|
||||
},
|
||||
})
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
|
||||
})
|
||||
|
||||
it('groups missing bundles under one source-build instruction with a package/path list', () => {
|
||||
const firstName = '@fixture/missing-first'
|
||||
const secondName = '@fixture/missing-second'
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
|
||||
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
|
||||
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310
|
||||
README.md: d5c0797c37168578f08a08f3d5d57670d7973db0
|
||||
README.zh.md: 57854213c3a9ea28850665eb26d07bc824c017e8
|
||||
|
||||
@@ -8,7 +8,7 @@ The doubles implement the same outward faces features receive through ctx (`Test
|
||||
|
||||
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
|
||||
|
||||
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
|
||||
Not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。
|
||||
不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* declaration, registration, scope, store, inject, rendering, updates, and
|
||||
* disposal without hand-building the machinery per suite.
|
||||
*
|
||||
* Not part of the product plugin graph (no `dshClient`); feature packages
|
||||
* Not part of the product plugin graph (no `dsh.client`); feature packages
|
||||
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
|
||||
* machinery — everything mounts the production implementations.
|
||||
* @module @deepseek-ai/dsh-client-test-runtime
|
||||
|
||||
@@ -22,15 +22,17 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Agent-preset surface plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* General-settings row through exports["./client"], discovered from the
|
||||
* package.json dshClient declaration.
|
||||
* package.json dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Command UI plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration. The host command registry itself mounts separately
|
||||
* (bootHost + CommandService).
|
||||
*/
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Deliverables plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration.
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Goal surface plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Permission surfaces plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* new-session Settings row and current-session command picker through
|
||||
* exports["./client"], discovered from the package.json dshClient declaration.
|
||||
* exports["./client"], discovered from the package.json dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Plan control plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration. Plan behavior itself (the /plan command, the plan projection
|
||||
* unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`,
|
||||
* composed independently on the host roster.
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -27,7 +27,7 @@ export type {
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-sidebar's apply, whose activation order relative to this one is NOT
|
||||
* constrained (dshClient.inject edges are informational); registration
|
||||
* constrained (dsh.client.inject edges are informational); registration
|
||||
* depends on the slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots']
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Skill reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Slash trigger plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration.
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,15 +22,17 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Subagent reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
|
||||
@@ -23,14 +23,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -37,7 +37,7 @@ const NS = 'workspace'
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* to this one is NOT constrained: dsh.client.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore depends on each slot
|
||||
* declaration through `slots.inject()` instead of assuming order.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
|
||||
* follow the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration).
|
||||
* through the package.json dsh.client declaration).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the workspace picker plugin. */
|
||||
|
||||
@@ -56,12 +56,14 @@
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +57,13 @@
|
||||
"react": "^18.2.0",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,7 @@ async function tightenModes(dir: string): Promise<void> {
|
||||
if (entry.isDirectory()) {
|
||||
await tightenModes(target)
|
||||
} else {
|
||||
/* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state;
|
||||
* the Windows native gate preserves the DACL while the POSIX suite covers this branch. */
|
||||
/* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */
|
||||
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', {
|
||||
dwThreadId: 'uint32',
|
||||
})
|
||||
|
||||
/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */
|
||||
if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) {
|
||||
throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`)
|
||||
}
|
||||
if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) {
|
||||
throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/**
|
||||
* Allocate one pointer-sized slot (for `T **` out-parameters).
|
||||
|
||||
@@ -408,6 +408,8 @@ export class AclSandbox {
|
||||
freeSidBestEffort(api, sidPtr, label, failures)
|
||||
}
|
||||
const token = this.token
|
||||
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
|
||||
has its token; the guard mirrors the write-SID guard. */
|
||||
if (token !== undefined) {
|
||||
try {
|
||||
if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token')
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* ACL failure-path tests with stub binding tables (the failure-paths.spec.ts
|
||||
* pattern): every checked Win32 call in the lock, read-merge-write, and
|
||||
* grant-skip sequence has a failing counterpart, and each failure closes the
|
||||
* handles it created before throwing. The exact-ACE skip and the DACL-walk
|
||||
* defenses are driven through crafted in-memory ACL/SID buffers. Pure
|
||||
* stubs — no real Win32 calls, so these run on every platform; the
|
||||
* real-FFI round-trip lives in acl.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts'
|
||||
import { allocBytes, ptrAddress } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */
|
||||
function aclApi(overrides: Partial<Win32Bindings> = {}): Win32Bindings {
|
||||
return {
|
||||
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
|
||||
const temp = tmpdir().replace(/[\\/]$/u, '')
|
||||
buffer.write(temp, 'utf16le')
|
||||
return temp.length
|
||||
}),
|
||||
createFileW: vi.fn(() => 7n),
|
||||
lockFileEx: vi.fn(() => 1),
|
||||
unlockFileEx: vi.fn(() => 1),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
koffi.encode(newAcl, PVOID, 9n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW: vi.fn(() => 0),
|
||||
localFree: vi.fn(() => 0n as NativePtr),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
}
|
||||
|
||||
/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */
|
||||
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr {
|
||||
const sid = allocBytes(8)
|
||||
koffi.encode(sid, 'uint8', revision)
|
||||
koffi.encode(sid, 1, 'uint8', count)
|
||||
authority.forEach((byte, index) => {
|
||||
koffi.encode(sid, 2 + index, 'uint8', byte)
|
||||
})
|
||||
return sid
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-memory ACL carrying the exact grant ACE the skip checks for:
|
||||
* header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE
|
||||
* (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects
|
||||
* whether the inline SID bytes equal `sid`.
|
||||
*/
|
||||
function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr {
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2) // AclRevision
|
||||
koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE
|
||||
koffi.encode(acl, 4, 'uint16', 1) // AceCount
|
||||
const ace = 8
|
||||
koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE)
|
||||
koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT)
|
||||
koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID
|
||||
koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK)
|
||||
const inlineSid = ace + 8
|
||||
for (let offset = 0; offset < 8; offset++) {
|
||||
koffi.encode(acl, inlineSid + offset, 'uint8', match
|
||||
? koffi.decode(sid, offset, 'uint8') as number
|
||||
: offset === 0 ? 9 : 0)
|
||||
}
|
||||
return acl
|
||||
}
|
||||
|
||||
describe('withPathLock failure paths', () => {
|
||||
it('fails closed when CreateFileW returns an invalid handle', () => {
|
||||
const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateFileW')
|
||||
})
|
||||
|
||||
it('closes the handle and reports when LockFileEx fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LockFileEx')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('closes the handle and reports when UnlockFileEx fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('UnlockFileEx')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('reports a failed CloseHandle after a successful action', () => {
|
||||
const api = aclApi({ closeHandle: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CloseHandle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAndApply failure paths', () => {
|
||||
it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => {
|
||||
const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('reports a NULL merged ACL when there is no descriptor to free', () => {
|
||||
const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('frees the descriptor and reports when SetEntriesInAclW fails', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn(() => 5),
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('frees the descriptor and reports a NULL merged ACL', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree })
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW')
|
||||
expect(localFree).toHaveBeenCalledWith(9n)
|
||||
})
|
||||
|
||||
it('reports a failed descriptor LocalFree after a successful apply', () => {
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
|
||||
it('reports a failed merged-ACL LocalFree after a successful apply', () => {
|
||||
// No existing descriptor (the default stub): the merge's only LocalFree
|
||||
// is the merged ACL's, which "fails" and is checked after the apply.
|
||||
const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) })
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the exact-ACE skip and DACL-walk defenses', () => {
|
||||
it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(localFree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr),
|
||||
})
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
|
||||
it('falls back to the merge path when the standing ACE names a different SID', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats an implausibly small ACL size as no exact grant', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2)
|
||||
koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header
|
||||
koffi.encode(acl, 4, 'uint16', 1)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(acl))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats an ACE that would overrun the ACL as no exact grant', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2)
|
||||
koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE
|
||||
koffi.encode(acl, 4, 'uint16', 1)
|
||||
koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(acl))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revokeWrite no-DACL path', () => {
|
||||
it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => {
|
||||
// The default stub encodes a NULL DACL and a NULL descriptor.
|
||||
const api = aclApi()
|
||||
const sid = craftSid(1, 0)
|
||||
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
|
||||
})
|
||||
|
||||
it('frees a descriptor that carries no DACL and reports nothing to revoke', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('reports a failed descriptor LocalFree on the no-DACL path', () => {
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr),
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
revokeWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,8 @@ import koffi from 'koffi'
|
||||
import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
@@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => {
|
||||
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
|
||||
})
|
||||
})
|
||||
|
||||
/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */
|
||||
function pipeOkApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const closed: bigint[] = []
|
||||
let next = 1n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
|
||||
koffi.encode(readSlot, PVOID, next++)
|
||||
koffi.encode(writeSlot, PVOID, next++)
|
||||
return 1
|
||||
}),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
describe('spawn pipe failures close their handles', () => {
|
||||
const token = 1n as NativePtr
|
||||
|
||||
it('spawnSandboxed reports a CreatePipe failure', () => {
|
||||
const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreatePipe')
|
||||
})
|
||||
|
||||
it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => {
|
||||
const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreatePipe')
|
||||
})
|
||||
|
||||
it('spawnSandboxed reports a SetHandleInformation failure', () => {
|
||||
const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetHandleInformation')
|
||||
})
|
||||
|
||||
it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => {
|
||||
const { api } = pipeOkApi({
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
})
|
||||
expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
|
||||
.toThrow(/null process\/thread handles/u)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnSandboxedInherited failure paths', () => {
|
||||
const token = 1n as NativePtr
|
||||
|
||||
/** The stub the inherited-happy path needs; overrides flip one call per test. */
|
||||
function inheritedApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const closed: bigint[] = []
|
||||
let std = 50n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createJobObjectW: vi.fn(() => 100n),
|
||||
setInformationJobObject: vi.fn(() => 1),
|
||||
getStdHandle: vi.fn(() => std++),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
assignProcessToJobObject: vi.fn(() => 1),
|
||||
resumeThread: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
it('closes the job and reports when GetStdHandle yields a NULL handle', () => {
|
||||
const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetStdHandle')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('reports a SetHandleInformation failure while enabling stdio inheritance', () => {
|
||||
const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetHandleInformation')
|
||||
})
|
||||
|
||||
it('closes the job and reports when CreateProcessAsUserW fails', () => {
|
||||
const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and rejects NULL process/thread handles after a successful spawn', () => {
|
||||
const { api, closeHandle } = inheritedApi({
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
})
|
||||
expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
|
||||
.toThrow(/null process\/thread handles/u)
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and reports when SetInformationJobObject fails', () => {
|
||||
const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetInformationJobObject')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and reports a NULL job object', () => {
|
||||
const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateJobObjectW')
|
||||
})
|
||||
|
||||
it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => {
|
||||
const { api, closeHandle } = inheritedApi()
|
||||
const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
expect(spawned.pid).toBe(1234)
|
||||
expect(spawned.process).toBe(200n)
|
||||
expect(spawned.job).toBe(100n)
|
||||
// thread handle closed by the spawn; process and job handles stay with the caller.
|
||||
expect(closeHandle).toHaveBeenCalledWith(201n)
|
||||
expect(closeHandle).not.toHaveBeenCalledWith(200n)
|
||||
expect(closeHandle).not.toHaveBeenCalledWith(100n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drainPipe', () => {
|
||||
it('stops at ERROR_NO_DATA and closes the read end', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => abi.ERROR_NO_DATA),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return drainPipe(api, 30n as NativePtr).then((buffer) => {
|
||||
expect(buffer.length).toBe(0)
|
||||
expect(closeHandle).toHaveBeenCalledWith(30n)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a PeekNamedPipe failure that is not a clean EOF', () => {
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' })
|
||||
})
|
||||
|
||||
it('reports a ReadFile failure after data was reported available', () => {
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
|
||||
koffi.encode(totalAvail, 'uint32', 4)
|
||||
return 1
|
||||
}),
|
||||
readFile: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' })
|
||||
})
|
||||
|
||||
it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => {
|
||||
let peeks = 0
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
|
||||
peeks++
|
||||
if (peeks > 1) return 0
|
||||
koffi.encode(totalAvail, 'uint32', 4)
|
||||
return 1
|
||||
}),
|
||||
readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => {
|
||||
chunk.write('ab', 0, 'utf8')
|
||||
koffi.encode(read, 'uint32', 2)
|
||||
return 1
|
||||
}),
|
||||
getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return drainPipe(api, 30n as NativePtr).then((buffer) => {
|
||||
expect(buffer.toString('utf8')).toBe('ab')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForExit', () => {
|
||||
it('reports a WaitForSingleObject failure', () => {
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0xFFFFFFFF),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
|
||||
})
|
||||
|
||||
it('reports a GetExitCodeProcess failure', () => {
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0),
|
||||
getExitCodeProcess: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
|
||||
})
|
||||
|
||||
it('returns the exit code and closes the process handle', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0),
|
||||
getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, 'uint32', 42)
|
||||
return 1
|
||||
}),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(waitForExit(api, 200n as NativePtr)).toBe(42)
|
||||
expect(closeHandle).toHaveBeenCalledWith(200n)
|
||||
})
|
||||
})
|
||||
|
||||
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal file
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* FFI helper tests with stub binding tables (the failure-paths.spec.ts
|
||||
* pattern): error formatting and temp-path decoding defenses, the
|
||||
* last-error throwers' detail fallback, pointer decode NULL handling, and
|
||||
* the bounded SID comparison's early exits. Pure stubs — no real Win32
|
||||
* calls, so these run on every platform; the real-FFI round-trip lives in
|
||||
* acl.spec.ts and probe.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import {
|
||||
allocBytes, decodePtr, decodePtrAt, errorText, getTempPath,
|
||||
isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32,
|
||||
} from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */
|
||||
function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType<typeof vi.fn> } {
|
||||
const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => {
|
||||
const text = 'access denied'
|
||||
buffer.write(text, 'utf16le')
|
||||
return text.length
|
||||
})
|
||||
const api = {
|
||||
formatMessageW,
|
||||
getLastError: vi.fn(() => 5),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, formatMessageW }
|
||||
}
|
||||
|
||||
/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */
|
||||
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr {
|
||||
const sid = allocBytes(8 + subs.length * 4)
|
||||
koffi.encode(sid, 'uint8', revision)
|
||||
koffi.encode(sid, 1, 'uint8', count)
|
||||
authority.forEach((byte, index) => {
|
||||
koffi.encode(sid, 2 + index, 'uint8', byte)
|
||||
})
|
||||
subs.forEach((sub, index) => {
|
||||
koffi.encode(sid, 8 + index * 4, 'uint32', sub)
|
||||
})
|
||||
return sid
|
||||
}
|
||||
|
||||
describe('errorText', () => {
|
||||
it('decodes the formatted UTF-16 message and trims it', () => {
|
||||
const { api } = formatApi()
|
||||
expect(errorText(api, 5)).toBe('access denied')
|
||||
})
|
||||
|
||||
it('returns an empty string when FormatMessageW formats nothing', () => {
|
||||
const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
expect(errorText(api, 5)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempPath', () => {
|
||||
it('decodes the NUL-terminated temp path GetTempPathW wrote', () => {
|
||||
const api = {
|
||||
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
|
||||
buffer.write('C:\\TEMP', 'utf16le')
|
||||
return 7
|
||||
}),
|
||||
} as unknown as Win32Bindings
|
||||
expect(getTempPath(api)).toBe('C:\\TEMP')
|
||||
})
|
||||
|
||||
it('reports the Win32 failure when GetTempPathW writes nothing', () => {
|
||||
const { api } = formatApi()
|
||||
const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
getTempPath(failing)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTempPathW')
|
||||
})
|
||||
})
|
||||
|
||||
describe('throwLastError and throwWin32', () => {
|
||||
it('throwLastError formats the system message when no detail is given', () => {
|
||||
const { api } = formatApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
throwLastError(api, 'Probe')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
|
||||
})
|
||||
|
||||
it('throwWin32 formats the system message when no detail is given', () => {
|
||||
const { api } = formatApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
throwWin32(api, 'Probe', 5)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
|
||||
})
|
||||
|
||||
it('Win32Error appends the detail when one is given', () => {
|
||||
const error = new Win32Error('Probe', 5, 'the lock file path')
|
||||
expect(error.name).toBe('Win32Error')
|
||||
expect(error.api).toBe('Probe')
|
||||
expect(error.win32Code).toBe(5)
|
||||
expect(error.message).toBe('Probe failed (Win32 5): the lock file path')
|
||||
})
|
||||
|
||||
it('Win32Error omits the detail suffix when none is given', () => {
|
||||
const error = new Win32Error('Probe', 5)
|
||||
expect(error.message).toBe('Probe failed (Win32 5)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointer NULL handling', () => {
|
||||
it('isNullPtr accepts null, undefined, and the zero pointer', () => {
|
||||
expect(isNullPtr(null)).toBe(true)
|
||||
expect(isNullPtr(undefined)).toBe(true)
|
||||
expect(isNullPtr(0n as NativePtr)).toBe(true)
|
||||
expect(isNullPtr(42n as NativePtr)).toBe(false)
|
||||
})
|
||||
|
||||
it('isInvalidHandle treats NULL as failure', () => {
|
||||
expect(isInvalidHandle(null)).toBe(true)
|
||||
expect(isInvalidHandle(undefined)).toBe(true)
|
||||
expect(isInvalidHandle(0n as NativePtr)).toBe(true)
|
||||
expect(isInvalidHandle(42n as NativePtr)).toBe(false)
|
||||
})
|
||||
|
||||
it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeBigUInt64LE(0n, 0)
|
||||
expect(decodePtrAt(buffer, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('decodePtrAt returns the stored pointer value', () => {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeBigUInt64LE(42n, 0)
|
||||
expect(decodePtrAt(buffer, 0)).toBe(42n)
|
||||
})
|
||||
|
||||
it('decodePtr returns null for an unset out-parameter slot', () => {
|
||||
const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr
|
||||
expect(decodePtr(slot)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameSidAt bounded comparison', () => {
|
||||
it('rejects a revision mismatch before comparing anything else', () => {
|
||||
const left = craftSid(1, 0)
|
||||
const right = craftSid(2, 0)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a subauthority-count mismatch', () => {
|
||||
const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
|
||||
const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43])
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an implausible subauthority count', () => {
|
||||
const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
|
||||
const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a differing identifier authority byte', () => {
|
||||
const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5])
|
||||
const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6])
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => {
|
||||
const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
|
||||
// Embed the same SID bytes at offset 4 of two buffers whose first four
|
||||
// bytes differ: an offset-ignoring comparison reads the differing
|
||||
// prefixes and must reject.
|
||||
const left = allocBytes(4 + 12)
|
||||
const right = allocBytes(4 + 12)
|
||||
koffi.encode(left, 0, 'uint32', 0x11111111)
|
||||
koffi.encode(right, 0, 'uint32', 0x22222222)
|
||||
for (let offset = 0; offset < 12; offset++) {
|
||||
const byte = koffi.decode(sid, offset, 'uint8') as number
|
||||
koffi.encode(left, 4 + offset, 'uint8', byte)
|
||||
koffi.encode(right, 4 + offset, 'uint8', byte)
|
||||
}
|
||||
expect(sameSidAt(left, 4, right, 4)).toBe(true)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* AclSandbox orchestration failure-path tests: the win32 resolver is mocked
|
||||
* to hand each test a stub binding table, so every checked Win32 call in
|
||||
* init/spawn/dispose has a failing counterpart without opening real token or
|
||||
* ACL handles. Constructor validation, the fail-closed init cleanup, and the
|
||||
* dispose aggregation use the same stubs. Pure stubs — no real Win32 calls,
|
||||
* so these run on every platform; the real-FFI round-trip lives in
|
||||
* acl.spec.ts and runner.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { PROCESS_INFORMATION } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
type MockFn = ReturnType<typeof vi.fn>
|
||||
|
||||
/** The stub binding table plus the mocks the assertions inspect directly. */
|
||||
interface HappyStubs {
|
||||
api: Win32Bindings
|
||||
setNamedSecurityInfoW: MockFn
|
||||
convertStringSidToSidW: MockFn
|
||||
closeHandle: MockFn
|
||||
localFree: MockFn
|
||||
createRestrictedToken: MockFn
|
||||
createJobObjectW: MockFn
|
||||
getNamedSecurityInfoW: MockFn
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined }))
|
||||
|
||||
vi.mock('../src/ffi.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/ffi.ts')>()
|
||||
return {
|
||||
...actual,
|
||||
win32: () => Promise.resolve(state.stubs?.api as Win32Bindings),
|
||||
win32Sync: () => state.stubs?.api as Win32Bindings,
|
||||
}
|
||||
})
|
||||
|
||||
const scratchDirs: string[] = []
|
||||
afterAll(() => {
|
||||
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* The stub the whole happy pipeline needs: token opening, capability-SID
|
||||
* parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token,
|
||||
* default-DACL merge, piped/inherited spawns, drains, and exit waits all
|
||||
* succeed. Every test flips one call per branch.
|
||||
*/
|
||||
function happyStubs(): HappyStubs {
|
||||
let next = 0n
|
||||
const fresh = () => ++next
|
||||
|
||||
const openProcess = vi.fn(() => fresh())
|
||||
const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const getTempPathW = vi.fn((_length: number, buffer: Buffer) => {
|
||||
const temp = tmpdir().replace(/[\\/]$/u, '')
|
||||
buffer.write(temp, 'utf16le')
|
||||
return temp.length
|
||||
})
|
||||
const createFileW = vi.fn(() => fresh())
|
||||
const getNamedSecurityInfoW = vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
})
|
||||
const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
koffi.encode(newAcl, PVOID, fresh())
|
||||
return 0
|
||||
})
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (cls === abi.TokenGroups) {
|
||||
info.writeUInt32LE(1, 0)
|
||||
info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET)
|
||||
info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8)
|
||||
} else {
|
||||
info.writeBigUInt64LE(88n, 0) // the token's current default DACL
|
||||
}
|
||||
return 1
|
||||
})
|
||||
const getLengthSid = vi.fn(() => 12)
|
||||
const copySid = vi.fn(() => 1)
|
||||
const createWellKnownSid = vi.fn(() => 1)
|
||||
const isValidSid = vi.fn(() => 1)
|
||||
const createRestrictedToken = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
_rc: unknown, _rs: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const setTokenInformation = vi.fn(() => 1)
|
||||
const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
|
||||
koffi.encode(readSlot, PVOID, fresh())
|
||||
koffi.encode(writeSlot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const setHandleInformation = vi.fn(() => 1)
|
||||
const createProcessAsUserW = vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
})
|
||||
const peekNamedPipe = vi.fn(() => 0)
|
||||
const readFile = vi.fn(() => 1)
|
||||
const waitForSingleObject = vi.fn(() => 0)
|
||||
const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, 'uint32', 42)
|
||||
return 1
|
||||
})
|
||||
const createJobObjectW = vi.fn(() => fresh())
|
||||
const setInformationJobObject = vi.fn(() => 1)
|
||||
const assignProcessToJobObject = vi.fn(() => 1)
|
||||
const resumeThread = vi.fn(() => 0)
|
||||
const getStdHandle = vi.fn(() => fresh())
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF
|
||||
const formatMessageW = vi.fn(() => 0)
|
||||
|
||||
const api = {
|
||||
openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW,
|
||||
lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1),
|
||||
getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation,
|
||||
getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken,
|
||||
setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW,
|
||||
peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW,
|
||||
setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle,
|
||||
localFree, closeHandle, getLastError, formatMessageW,
|
||||
} as unknown as Win32Bindings
|
||||
return {
|
||||
api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
|
||||
createRestrictedToken, createJobObjectW, getNamedSecurityInfoW,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.stubs = happyStubs()
|
||||
})
|
||||
|
||||
describe('AclSandbox constructor validation', () => {
|
||||
it('rejects a writable directory that does not exist', () => {
|
||||
const missing = join(scratch(), 'missing')
|
||||
expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' }))
|
||||
.toThrow(/writable dir does not exist/u)
|
||||
})
|
||||
|
||||
it('resolves relative writable directories to absolute paths', () => {
|
||||
const dir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
|
||||
expect(sandbox.writableDirs).toEqual([resolve(dir)])
|
||||
expect(sandbox.mode).toBe('read-only')
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox init', () => {
|
||||
it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-1',
|
||||
tempWriteSid: 'S-1-4-9000-1-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
expect(sandbox.tempDir).toBe(resolve(temp))
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('requires an explicit private temp directory or null under workspace-write', () => {
|
||||
const workspace = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
|
||||
.toThrow(/requires an explicit private temp directory or null/u)
|
||||
})
|
||||
|
||||
it('applies no grants when the temp dir option is null', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
|
||||
})
|
||||
|
||||
it('rejects a temp dir that does not exist', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: join(scratch(), 'missing'),
|
||||
writeSid: 'S-1-4-9000-4',
|
||||
tempWriteSid: 'S-1-4-9000-4-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u)
|
||||
})
|
||||
|
||||
it('builds a read-only token without parsing a write SID or applying grants', async () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
|
||||
await sandbox.init()
|
||||
expect(convertStringSidToSidW).not.toHaveBeenCalled()
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
|
||||
})
|
||||
|
||||
it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
|
||||
})
|
||||
|
||||
it('refuses a second init on the same instance', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
|
||||
})
|
||||
|
||||
it('reports a ConvertStringSidToSidW failure before granting anything', async () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockReturnValue(0)
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
|
||||
const { convertStringSidToSidW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
|
||||
})
|
||||
|
||||
it('reports a failed close of the current process token', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
|
||||
// fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the
|
||||
// token-layer close of 1n succeeds and init's close of 2n fails.
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1))
|
||||
// The failure lands after this.token is stored but before this.api is
|
||||
// assigned. Cleanup retries the still-open handle and reports both close
|
||||
// failures after releasing the restricted token and parsed SIDs.
|
||||
await expect(sandbox.init()).rejects.toMatchObject({
|
||||
errors: [
|
||||
{ api: 'CloseHandle' },
|
||||
{ api: 'CloseHandle' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => {
|
||||
const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
let inCleanup = false
|
||||
createRestrictedToken.mockImplementation(() => {
|
||||
inCleanup = true // the grants already landed: every later call is the cleanup's
|
||||
return 0
|
||||
})
|
||||
localFree.mockImplementation(() => (inCleanup ? 1n : 0n))
|
||||
getNamedSecurityInfoW.mockImplementation((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
if (inCleanup) return 2 // the cleanup's revocation read fails too
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
})
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-10',
|
||||
tempWriteSid: 'S-1-4-9000-10-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u)
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox spawn', () => {
|
||||
it('refuses to spawn before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
|
||||
expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
|
||||
})
|
||||
|
||||
it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
|
||||
expect(child.pid).toBe(1234)
|
||||
const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }
|
||||
await expect(child.wait()).resolves.toEqual(expected)
|
||||
// The second wait reuses the settled exit-code promise instead of re-waiting.
|
||||
await expect(child.wait()).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
|
||||
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
|
||||
expect(closeHandle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inherit spawn reports a failed close of the kill-on-close job', async () => {
|
||||
const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
let jobHandle = 0n
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
|
||||
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
|
||||
jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr
|
||||
await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox dispose', () => {
|
||||
it('is a no-op before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
|
||||
expect(() => { sandbox.dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('aggregates a failing temp revocation into an AggregateError', async () => {
|
||||
const { getNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-16',
|
||||
tempWriteSid: 'S-1-4-9000-16-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
getNamedSecurityInfoW.mockReturnValue(2)
|
||||
expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u)
|
||||
})
|
||||
|
||||
it('aggregates SID and token cleanup failures into an AggregateError', async () => {
|
||||
const { localFree } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
localFree.mockReturnValue(1n)
|
||||
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
|
||||
it('reports a failed close of the restricted token', async () => {
|
||||
const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
|
||||
let restrictedToken = 0n
|
||||
createRestrictedToken.mockImplementation((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
_rc: unknown, _rs: unknown, slot: NativePtr,
|
||||
) => {
|
||||
restrictedToken = 99n
|
||||
koffi.encode(slot, PVOID, restrictedToken)
|
||||
return 1
|
||||
})
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1))
|
||||
await sandbox.init()
|
||||
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Restricted-token failure-path tests with stub binding tables (the
|
||||
* failure-paths.spec.ts pattern): every checked Win32 call in the token
|
||||
* pipeline — open, logon-SID scan, well-known SID creation, default-DACL
|
||||
* merge, restricted-token creation — has a failing counterpart, and each
|
||||
* failure closes or frees what it created before throwing. Pure stubs — no
|
||||
* real Win32 calls, so these run on every platform; the real-FFI round-trip
|
||||
* lives in acl.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { allocBytes, isNullPtr } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import {
|
||||
createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant,
|
||||
} from '../src/token.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
describe('openCurrentProcessToken failure paths', () => {
|
||||
it('reports when OpenProcess yields no handle', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 0n),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcess')
|
||||
})
|
||||
|
||||
it('closes the process handle and reports when OpenProcessToken fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn(() => 0),
|
||||
closeHandle,
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcessToken')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('reports a failed CloseHandle of the process handle', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
return 1
|
||||
}),
|
||||
closeHandle: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CloseHandle')
|
||||
})
|
||||
|
||||
it('rejects a NULL token handle after a successful OpenProcessToken', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot
|
||||
closeHandle: vi.fn(() => 1),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcessToken')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The stub the logon-SID scan needs: the size probe writes `needed`, the
|
||||
* second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8,
|
||||
* attributes@16) with the state's one group. The CopySid mock comes back
|
||||
* beside the table for the one test that asserts on its arguments.
|
||||
*/
|
||||
function logonApi(state: {
|
||||
needed: number
|
||||
groupCount: number
|
||||
sidPtr: bigint
|
||||
logon: boolean
|
||||
secondOk?: boolean
|
||||
sidLength?: number
|
||||
copyOk?: boolean
|
||||
}): { api: Win32Bindings; copySid: ReturnType<typeof vi.fn> } {
|
||||
const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1))
|
||||
const api = {
|
||||
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`)
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', state.needed)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (state.secondOk === false) return 0
|
||||
info.writeUInt32LE(state.groupCount, 0)
|
||||
if (state.groupCount > 0) {
|
||||
info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET)
|
||||
info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8)
|
||||
}
|
||||
return 1
|
||||
}),
|
||||
getLengthSid: vi.fn(() => state.sidLength ?? 12),
|
||||
copySid,
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, copySid }
|
||||
}
|
||||
|
||||
describe('findLogonSid failure paths', () => {
|
||||
const token = 9n as NativePtr
|
||||
|
||||
it('reports a size probe that wrote nothing', () => {
|
||||
const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('rejects an implausibly small TokenGroups size', () => {
|
||||
const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('reports a failed TokenGroups read', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('skips a NULL group SID pointer and throws when no logon SID remains', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true })
|
||||
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
|
||||
})
|
||||
|
||||
it('skips a non-logon group and throws when no logon SID remains', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false })
|
||||
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
|
||||
})
|
||||
|
||||
it('reports a zero logon-SID length', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetLengthSid')
|
||||
})
|
||||
|
||||
it('reports a failed CopySid of the logon SID', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CopySid')
|
||||
})
|
||||
|
||||
it('copies the logon SID and returns the new allocation', () => {
|
||||
const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true })
|
||||
const copy = findLogonSid(api, token)
|
||||
expect(isNullPtr(copy)).toBe(false)
|
||||
expect(copySid).toHaveBeenCalledWith(12, copy, 77n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeWellKnownSid failure paths', () => {
|
||||
it('reports when CreateWellKnownSid fails', () => {
|
||||
const api = {
|
||||
createWellKnownSid: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
makeWellKnownSid(api, abi.WinWorldSid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateWellKnownSid')
|
||||
})
|
||||
|
||||
it('reports when the created well-known SID is invalid', () => {
|
||||
const api = {
|
||||
createWellKnownSid: vi.fn(() => 1),
|
||||
isValidSid: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
makeWellKnownSid(api, abi.WinWorldSid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('IsValidSid')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The stub the default-DACL merge needs: the size probe writes `needed`, the
|
||||
* second call fills the DACL pointer slot, and the merge/apply calls follow
|
||||
* the state's results.
|
||||
*/
|
||||
function daclApi(state: {
|
||||
needed: number
|
||||
currentDacl: bigint
|
||||
secondOk?: boolean
|
||||
mergeResult?: number
|
||||
newDacl: bigint
|
||||
setTokenInfo?: number
|
||||
}): Win32Bindings {
|
||||
const api = {
|
||||
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`)
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', state.needed)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (state.secondOk === false) return 0
|
||||
info.writeBigUInt64LE(state.currentDacl, 0)
|
||||
return 1
|
||||
}),
|
||||
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult
|
||||
koffi.encode(newAcl, PVOID, state.newDacl)
|
||||
return 0
|
||||
}),
|
||||
setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1),
|
||||
localFree: vi.fn(() => 0n),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return api
|
||||
}
|
||||
|
||||
describe('setTokenDefaultDaclGrant failure paths', () => {
|
||||
const token = 9n as NativePtr
|
||||
const sid = 77n as NativePtr
|
||||
|
||||
it('reports a size probe that wrote nothing', () => {
|
||||
const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('reports a failed default-DACL read', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('rejects a token that carries no default DACL', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n })
|
||||
expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u)
|
||||
})
|
||||
|
||||
it('reports a failed SetEntriesInAclW merge', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('rejects a NULL merged default DACL', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('frees the merged DACL and reports when SetTokenInformation fails', () => {
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 })
|
||||
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetTokenInformation')
|
||||
expect(localFree).toHaveBeenCalledWith(99n)
|
||||
})
|
||||
|
||||
it('frees the merged DACL after a successful apply', () => {
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n })
|
||||
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
expect(localFree).toHaveBeenCalledWith(99n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRestrictedToken failure paths', () => {
|
||||
it('builds the read-only restricting list without a write SID', () => {
|
||||
const create = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
count: number, _sids: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
expect(count).toBe(2)
|
||||
return 1
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')).toBe(9n)
|
||||
})
|
||||
|
||||
it('builds the workspace-write restricting list with the write SID', () => {
|
||||
const create = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
count: number, _sids: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
expect(count).toBe(3)
|
||||
return 1
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, [3n as NativePtr], { world: 2n as NativePtr }, 'workspace-write')).toBe(9n)
|
||||
})
|
||||
|
||||
it('reports when CreateRestrictedToken fails', () => {
|
||||
const api = {
|
||||
createRestrictedToken: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
|
||||
})
|
||||
|
||||
it('rejects a NULL token handle after a successful CreateRestrictedToken', () => {
|
||||
const api = {
|
||||
createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
|
||||
})
|
||||
})
|
||||
@@ -280,7 +280,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'clientModuleHost',
|
||||
summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
|
||||
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'graph(): WebBootGraph',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/generator/README.md
|
||||
README.md: 38030c2b7e07c70ab79001086640b6581943dbd9
|
||||
README.zh.md: 0cf0a365785775a9461e9744d29a0da482e54d14
|
||||
README.md: ac2610620af14a30143e95921273a8c254b50a17
|
||||
README.zh.md: a1fa777493578533c93db6cd3354b89c8757d837
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects.
|
||||
|
||||
The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project `dshClient` package may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded.
|
||||
The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project package declaring `dsh.client` may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded.
|
||||
|
||||
## Analysis Model
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。
|
||||
|
||||
分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接项目引用确定编译器 face 的成员归属,而包子路径确定 TypeRT 运行时 face 的贡献:带 `dshClient` 的普通单项目包可以同时贡献 Host 与 Client 运行时模型;只有通过 `tsconfig.host.json` 或 `tsconfig.client.json` 显式引用的拆分项目,才会被限制在相应 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。
|
||||
分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接项目引用确定编译器 face 的成员归属,而包子路径确定 TypeRT 运行时 face 的贡献:声明 `dsh.client` 的普通单项目包可以同时贡献 Host 与 Client 运行时模型;只有通过 `tsconfig.host.json` 或 `tsconfig.client.json` 显式引用的拆分项目,才会被限制在相应 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。
|
||||
|
||||
## 分析模型
|
||||
|
||||
|
||||
@@ -2564,8 +2564,12 @@ function hasPackageSurface(model: PackageModel): boolean {
|
||||
}
|
||||
|
||||
function isDualFacePackage(manifest: Record<string, unknown>): boolean {
|
||||
return manifest.dshClient !== null
|
||||
&& typeof manifest.dshClient === 'object'
|
||||
const dsh = manifest.dsh
|
||||
const client = dsh !== null && typeof dsh === 'object'
|
||||
? (dsh as Record<string, unknown>).client
|
||||
: undefined
|
||||
return client !== null
|
||||
&& typeof client === 'object'
|
||||
&& clientExportSubpaths(manifest).length > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -309,11 +309,11 @@ export interface RemainingSchema {
|
||||
const root = copyFixture()
|
||||
const manifestPath = join(root, 'packages/remote/package.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
||||
dshClient?: object
|
||||
dsh?: { client?: object }
|
||||
exports: Record<string, unknown>
|
||||
files: string[]
|
||||
}
|
||||
manifest.dshClient = {}
|
||||
manifest.dsh = { client: {} }
|
||||
manifest.exports['./client'] = './src/client.ts'
|
||||
manifest.exports['./client/typert'] = {
|
||||
types: './lib/typert.client.d.ts',
|
||||
@@ -332,8 +332,10 @@ export interface ClientMarker {
|
||||
}
|
||||
`)
|
||||
|
||||
expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face))
|
||||
.toEqual(['host', 'client'])
|
||||
const artifacts = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifacts.map(artifact => artifact.face)).toEqual(['host', 'client'])
|
||||
expect(artifacts.find(artifact => artifact.face === 'host')?.dts).not.toContain('ClientMarker')
|
||||
expect(artifacts.find(artifact => artifact.face === 'client')?.dts).toContain('ClientMarker')
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -869,7 +869,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
|
||||
.toEqual(['@fixture/host'])
|
||||
})
|
||||
|
||||
it('keeps both runtime faces for an ordinary dshClient project', () => {
|
||||
it('keeps both runtime faces for an ordinary dsh.client project', () => {
|
||||
const root = copyFixture('typert-dual-runtime-')
|
||||
configureDualRuntimeClient(root, false)
|
||||
|
||||
@@ -1227,10 +1227,10 @@ function configureDualRuntimeClient(root: string, splitProjects: boolean): void
|
||||
const packageRoot = join(root, 'packages/client')
|
||||
const manifestPath = join(packageRoot, 'package.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
||||
dshClient?: object
|
||||
dsh?: { client?: object }
|
||||
exports: Record<string, unknown>
|
||||
}
|
||||
manifest.dshClient = {}
|
||||
manifest.dsh = { client: {} }
|
||||
manifest.exports['./client'] = {
|
||||
types: './lib/types/client.d.ts',
|
||||
default: './lib/client.js',
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
Reference in New Issue
Block a user