初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* HTTP {@link UserFs}: every file operation is delegated to the user's own file
|
||||
* sidecar (docs/k8s.md §4.10), because the control plane runs as uid 65532 with
|
||||
* no users volume and could not touch a `0700` user directory even if it did.
|
||||
*
|
||||
* The sidecar is addressed through its per-user Headless Service. That DNS A
|
||||
* record has a ~30s TTL, so a Pod that was just rebuilt can still resolve to
|
||||
* its old IP — connection-level failures therefore drop the keep-alive pool and
|
||||
* retry once, mirroring what the DSH proxy does (docs/k8s.md §5.4).
|
||||
* @module dshs/fs/k8s-user-fs
|
||||
*/
|
||||
|
||||
import { Agent, request as httpRequest } from 'node:http'
|
||||
import type { Endpoint } from '../supervisor/spawner.js'
|
||||
import { POSIX, PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js'
|
||||
import type { PluginInfo } from './plugins.js'
|
||||
import { isUserFsErrorCode, UserFsError, type UserFs } from './user-fs.js'
|
||||
import { HOME_DIR, USERS_DIR, WORKSPACE_DIR, type FsEntry } from './workspace.js'
|
||||
|
||||
/** Data root inside every per-user Pod (mirrors `k8s-spawner`'s POD_DATA_ROOT). */
|
||||
const POD_DATA_ROOT = '/var/lib/dshs'
|
||||
|
||||
/** Errors that mean "the connection never got anywhere" — worth one retry
|
||||
* against a freshly resolved address. */
|
||||
const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ETIMEDOUT'])
|
||||
|
||||
/** Ensure the user's file sidecar exists and is ready; supplied by the spawner. */
|
||||
export type EnsureFileService = (userId: string) => Promise<void>
|
||||
|
||||
interface Reply {
|
||||
status: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export class K8sUserFs implements UserFs {
|
||||
private agent = new Agent({ keepAlive: true, maxSockets: 16 })
|
||||
|
||||
/**
|
||||
* @param ensureFileService - brings the sidecar up before the first call.
|
||||
* @param endpointFor - the sidecar's address; the k8s backend supplies the
|
||||
* per-user Headless Service DNS, tests supply a loopback bind.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ensureFileService: EnsureFileService,
|
||||
private readonly endpointFor: (userId: string) => Endpoint,
|
||||
) {}
|
||||
|
||||
async initUserRoot(userId: string): Promise<void> {
|
||||
// Creating the sidecar Pod *is* the initialization: its init container
|
||||
// builds `<pvc>/<userId>/{ws,home}` as the user's own uid (docs/k8s.md §4.9).
|
||||
await this.ensureFileService(userId)
|
||||
await this.call(userId, 'POST', '/fs/init')
|
||||
}
|
||||
|
||||
resolvePath(userId: string, relPath: string): string {
|
||||
const root = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${WORKSPACE_DIR}`
|
||||
try {
|
||||
return resolveWithinRoot(root, relPath, POSIX)
|
||||
} catch (err) {
|
||||
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's DSH state directory inside their Pod. */
|
||||
homePath(userId: string): string {
|
||||
return `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${HOME_DIR}`
|
||||
}
|
||||
|
||||
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
|
||||
const body = await this.call(userId, 'GET', `/fs/tree?path=${encodeURIComponent(relPath)}`)
|
||||
return (body as { entries: FsEntry[] }).entries
|
||||
}
|
||||
|
||||
async mkdir(userId: string, relPath: string): Promise<void> {
|
||||
await this.call(userId, 'POST', '/fs/mkdir', { path: relPath })
|
||||
}
|
||||
|
||||
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
|
||||
const body = await this.call(userId, 'POST', '/fs/create', { path: relPath, name, type })
|
||||
return (body as { name: string }).name
|
||||
}
|
||||
|
||||
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
|
||||
const body = await this.call(userId, 'POST', '/fs/upload', {
|
||||
path: relPath,
|
||||
name,
|
||||
data: data.toString('base64'),
|
||||
})
|
||||
return (body as { name: string }).name
|
||||
}
|
||||
|
||||
/** 档案 56:k8s 路径未验证 —— sidecar 尚无 read 端点,明确报 `unsupported` 而非静默失败。 */
|
||||
async readFile(): Promise<{ name: string; data: Buffer }> {
|
||||
throw new UserFsError('unsupported')
|
||||
}
|
||||
|
||||
async isDirectory(userId: string, relPath: string): Promise<boolean> {
|
||||
const body = await this.call(userId, 'GET', `/fs/stat?path=${encodeURIComponent(relPath)}`)
|
||||
return (body as { isDirectory: boolean }).isDirectory
|
||||
}
|
||||
|
||||
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
|
||||
const body = await this.call(userId, 'GET', '/fs/plugins')
|
||||
return (body as { plugins: PluginInfo[] }).plugins
|
||||
}
|
||||
|
||||
async writeHandoff(userId: string, content: string): Promise<void> {
|
||||
await this.call(userId, 'POST', '/fs/handoff', { content })
|
||||
}
|
||||
|
||||
/** One sidecar call: ensure the Pod, send, retry once on a dead connection,
|
||||
* then translate a non-2xx `{error}` back into a {@link UserFsError}. */
|
||||
private async call(userId: string, method: string, path: string, payload?: unknown): Promise<unknown> {
|
||||
await this.ensureFileService(userId)
|
||||
let reply: Reply
|
||||
try {
|
||||
reply = await this.send(userId, method, path, payload)
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === undefined || !RETRYABLE.has(code)) throw err
|
||||
// The keep-alive pool may hold sockets to a Pod IP that no longer exists;
|
||||
// drop them so the retry re-resolves the Headless Service.
|
||||
this.agent.destroy()
|
||||
this.agent = new Agent({ keepAlive: true, maxSockets: 16 })
|
||||
reply = await this.send(userId, method, path, payload)
|
||||
}
|
||||
if (reply.status >= 200 && reply.status < 300) return reply.body
|
||||
const error = (reply.body as { error?: unknown }).error
|
||||
if (typeof error === 'string' && isUserFsErrorCode(error)) throw new UserFsError(error)
|
||||
throw new Error(`file sidecar for ${userId} returned ${reply.status}`)
|
||||
}
|
||||
|
||||
private send(userId: string, method: string, path: string, payload?: unknown): Promise<Reply> {
|
||||
const body = payload === undefined ? undefined : JSON.stringify(payload)
|
||||
const endpoint = this.endpointFor(userId)
|
||||
return new Promise<Reply>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
path,
|
||||
method,
|
||||
agent: this.agent,
|
||||
headers: body === undefined
|
||||
? {}
|
||||
: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
try {
|
||||
resolve({ status: res.statusCode ?? 502, body: text === '' ? {} : JSON.parse(text) })
|
||||
} catch {
|
||||
reject(new Error(`file sidecar for ${userId} returned non-JSON (${res.statusCode})`))
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
req.on('error', reject)
|
||||
if (body !== undefined) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Direct-filesystem {@link UserFs}: the control plane owns the users volume and
|
||||
* touches it in-process. This is the `deployMode=local` implementation, and it
|
||||
* is also what the per-user file sidecar runs behind its HTTP surface — the
|
||||
* sidecar is just a `LocalUserFs` pinned to one user's root.
|
||||
* @module dshs/fs/local-user-fs
|
||||
*/
|
||||
|
||||
import { chownSync, chmodSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js'
|
||||
import { listInstalledPlugins, type PluginInfo } from './plugins.js'
|
||||
import { UserFsError, type UserFs } from './user-fs.js'
|
||||
import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js'
|
||||
|
||||
/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a
|
||||
* fixed directory when the sidecar serves exactly one user). */
|
||||
export type UserRootResolver = (userId: string) => string
|
||||
|
||||
/** Map a Node errno onto the wire code the desktop already handles. */
|
||||
function fsError(err: unknown, onMissing: 'not_found' | 'parent_missing'): never {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EEXIST') throw new UserFsError('exists')
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') throw new UserFsError(onMissing)
|
||||
throw err
|
||||
}
|
||||
|
||||
export class LocalUserFs implements UserFs {
|
||||
constructor(private readonly rootFor: UserRootResolver) {}
|
||||
|
||||
async initUserRoot(userId: string, uid?: number): Promise<void> {
|
||||
const root = this.rootFor(userId)
|
||||
ensureDir(homeRoot(root))
|
||||
ensureDir(workspaceRoot(root))
|
||||
// The roots were created by the (possibly root) control plane; the DSH child
|
||||
// runs as the user's own uid and must be able to traverse + write them.
|
||||
// chown the user root + home/ws so the child can mkdir profiles/, etc.
|
||||
if (uid !== undefined && typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
chownSync(root, uid, uid)
|
||||
chownSync(homeRoot(root), uid, uid)
|
||||
chownSync(workspaceRoot(root), uid, uid)
|
||||
// 0700 + sticky-ish owner only; keep group/other off.
|
||||
chmodSync(homeRoot(root), 0o700)
|
||||
chmodSync(workspaceRoot(root), 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
resolvePath(userId: string, relPath: string): string {
|
||||
return this.resolve(userId, relPath)
|
||||
}
|
||||
|
||||
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
|
||||
const abs = this.resolve(userId, relPath)
|
||||
await this.assertNoLinks(userId, abs)
|
||||
try {
|
||||
return await listDir(abs)
|
||||
} catch (err) {
|
||||
fsError(err, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(userId: string, relPath: string): Promise<void> {
|
||||
const abs = this.resolve(userId, relPath)
|
||||
await this.assertNoLinks(userId, abs)
|
||||
try {
|
||||
await mkdir(abs)
|
||||
} catch (err) {
|
||||
fsError(err, 'parent_missing')
|
||||
}
|
||||
}
|
||||
|
||||
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
|
||||
const dirAbs = this.resolve(userId, relPath)
|
||||
const filename = this.sanitize(name)
|
||||
const target = join(dirAbs, filename)
|
||||
await this.assertNoLinks(userId, target)
|
||||
try {
|
||||
if (type === 'dir') await mkdir(target)
|
||||
else await writeFile(target, '')
|
||||
} catch (err) {
|
||||
fsError(err, 'parent_missing')
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
|
||||
const dirAbs = this.resolve(userId, relPath)
|
||||
const filename = this.sanitize(name)
|
||||
const target = join(dirAbs, filename)
|
||||
await this.assertNoLinks(userId, target)
|
||||
try {
|
||||
await writeFile(target, data)
|
||||
} catch (err) {
|
||||
fsError(err, 'parent_missing')
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
/** 档案 56:读取工作区内的**文件**(门户/实例页「我的文件」下载用)。 */
|
||||
async readFile(
|
||||
userId: string,
|
||||
relPath: string,
|
||||
maxBytes = 32 * 1024 * 1024,
|
||||
): Promise<{ name: string; data: Buffer }> {
|
||||
const abs = this.resolve(userId, relPath)
|
||||
await this.assertNoLinks(userId, abs)
|
||||
let st
|
||||
try {
|
||||
st = await stat(abs)
|
||||
} catch (err) {
|
||||
fsError(err, 'not_found')
|
||||
}
|
||||
if (st.isDirectory()) throw new UserFsError('not_a_file')
|
||||
if (st.size > maxBytes) throw new UserFsError('too_large')
|
||||
try {
|
||||
return { name: basename(abs), data: await readFile(abs) }
|
||||
} catch (err) {
|
||||
fsError(err, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
async isDirectory(userId: string, relPath: string): Promise<boolean> {
|
||||
const abs = this.resolve(userId, relPath)
|
||||
await this.assertNoLinks(userId, abs)
|
||||
try {
|
||||
return (await stat(abs)).isDirectory()
|
||||
} catch (err) {
|
||||
fsError(err, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
|
||||
return listInstalledPlugins(this.rootFor(userId))
|
||||
}
|
||||
|
||||
async writeHandoff(userId: string, content: string): Promise<void> {
|
||||
const root = this.rootFor(userId)
|
||||
ensureDir(root)
|
||||
await writeFile(handoffPath(root), content)
|
||||
}
|
||||
|
||||
/** Resolve a workspace-relative path, self-healing the root the way the
|
||||
* pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */
|
||||
private resolve(userId: string, relPath: string): string {
|
||||
const ws = workspaceRoot(this.rootFor(userId))
|
||||
ensureDir(ws)
|
||||
try {
|
||||
return resolveWithinRoot(ws, relPath)
|
||||
} catch (err) {
|
||||
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** The lexical guard above is prefix-only and blind to links planted inside
|
||||
* the workspace, so every operation re-walks its final absolute path (the
|
||||
* write target for upload/createEntry) and rejects any symlink component —
|
||||
* `writeFile`/`mkdir` would otherwise follow it outside the root with this
|
||||
* process's privileges. Surfaces as the usual `bad_path` wire code. */
|
||||
private async assertNoLinks(userId: string, abs: string): Promise<void> {
|
||||
try {
|
||||
await rejectSymlinkEscape(workspaceRoot(this.rootFor(userId)), abs)
|
||||
} catch (err) {
|
||||
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(name: string): string {
|
||||
try {
|
||||
return safeFilename(name)
|
||||
} catch (err) {
|
||||
if (err instanceof PathEscapeError) throw new UserFsError('bad_name')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Per-user plugin discovery: read the resident DSH profile's `dsh.profile.bundles`
|
||||
* and surface user-installed bundles, filtering out installation-owned
|
||||
* `@deepseek-ai/*` bundles. Name/description come from each bundle's own
|
||||
* package.json.
|
||||
* @module dshs/fs/plugins
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homeRoot } from './workspace.js'
|
||||
|
||||
/** A plugin the user may enable per folder (`id` doubles as the package name). */
|
||||
export interface PluginInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Profile name the resident main DSH is launched with (see supervisor). */
|
||||
export const MAIN_PROFILE = 'web'
|
||||
|
||||
/** Bundles under this scope come from the dsh installation, not the user. */
|
||||
const INSTALLATION_SCOPE = '@deepseek-ai/'
|
||||
|
||||
/** Absolute profile directory within a user root (`<root>/home/profiles/web`). */
|
||||
function profileDir(root: string): string {
|
||||
return join(homeRoot(root), 'profiles', MAIN_PROFILE)
|
||||
}
|
||||
|
||||
/** Read a package.json `description`, tolerating a missing/empty field or file. */
|
||||
function readDescription(manifestPath: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { description?: unknown }
|
||||
return typeof parsed.description === 'string' ? parsed.description : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List a user's installed plugins from their profile's `dsh.profile.bundles`,
|
||||
* minus installation-owned bundles. Returns `[]` when the profile (or a listed
|
||||
* package) has not been installed yet. Order follows the bundle layer order.
|
||||
* @param root - the user's data root (see {@link userRoot}).
|
||||
*/
|
||||
export function listInstalledPlugins(root: string): PluginInfo[] {
|
||||
const dir = profileDir(root)
|
||||
let manifest: { dsh?: { profile?: { bundles?: string[] } } }
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const plugins: PluginInfo[] = []
|
||||
for (const packageName of bundles) {
|
||||
if (packageName.startsWith(INSTALLATION_SCOPE)) continue
|
||||
plugins.push({
|
||||
id: packageName,
|
||||
name: packageName,
|
||||
description: readDescription(join(dir, 'node_modules', packageName, 'package.json')),
|
||||
})
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* {@link UserFs} factory. Picks the implementation from `deployMode`, the same
|
||||
* way {@link createDbAdapter} picks a DB backend and `buildServer` picks a
|
||||
* {@link Spawner}.
|
||||
* @module dshs/fs/provider
|
||||
*/
|
||||
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import type { Endpoint } from '../supervisor/spawner.js'
|
||||
import { FILE_SERVICE_PORT } from '../web/file-service.js'
|
||||
import { K8sUserFs, type EnsureFileService } from './k8s-user-fs.js'
|
||||
import { LocalUserFs } from './local-user-fs.js'
|
||||
import type { UserFs } from './user-fs.js'
|
||||
import { userRoot } from './workspace.js'
|
||||
|
||||
/** Headless Service fronting a user's file sidecar (docs/k8s.md §4.10). */
|
||||
export function fileServiceName(userId: string): string {
|
||||
return `dsh-files-${userId}`
|
||||
}
|
||||
|
||||
/** In-cluster address of a user's file sidecar. */
|
||||
export function fileServiceEndpoint(namespace: string, userId: string): Endpoint {
|
||||
return { host: `${fileServiceName(userId)}.${namespace}.svc.cluster.local`, port: FILE_SERVICE_PORT }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the configured per-user filesystem.
|
||||
*
|
||||
* `local` touches the users volume in-process. `k8s` delegates to each user's
|
||||
* file sidecar, which the spawner brings up on demand via `ensureFileService`.
|
||||
*/
|
||||
export function createUserFs(config: ServerConfig, ensureFileService?: EnsureFileService): UserFs {
|
||||
if (config.deployMode === 'k8s' && ensureFileService !== undefined) {
|
||||
return new K8sUserFs(ensureFileService, (userId) => fileServiceEndpoint(config.k8sNamespace, userId))
|
||||
}
|
||||
return new LocalUserFs((userId) => userRoot(config.dataRoot, userId))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The per-user filesystem seam (docs/k8s.md §4.10 / §6.0-1).
|
||||
*
|
||||
* Under `local` the control plane owns the users volume and touches it directly
|
||||
* ({@link LocalUserFs}). Under `k8s` it must not: it runs as uid 65532 while
|
||||
* each user's directory is `0700` owned by that user's uid, so every file
|
||||
* operation is delegated over HTTP to a per-user file sidecar
|
||||
* ({@link K8sUserFs}). Routes depend only on this interface.
|
||||
*
|
||||
* All paths crossing this interface are **workspace-relative**; each
|
||||
* implementation resolves them against its own root via `resolveWithinRoot`.
|
||||
* @module dshs/fs/user-fs
|
||||
*/
|
||||
|
||||
import type { PluginInfo } from './plugins.js'
|
||||
import type { FsEntry } from './workspace.js'
|
||||
|
||||
/** Wire-level failure codes. These are the exact `{error}` values the desktop
|
||||
* UI already switches on, so they survive the control-plane ↔ sidecar hop. */
|
||||
export type UserFsErrorCode =
|
||||
| 'bad_path'
|
||||
| 'bad_name'
|
||||
| 'not_found'
|
||||
| 'exists'
|
||||
| 'parent_missing'
|
||||
| 'not_a_folder'
|
||||
// 档案 56:下载/查看文件
|
||||
| 'not_a_file'
|
||||
| 'too_large'
|
||||
// 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径)
|
||||
| 'unsupported'
|
||||
|
||||
/** HTTP status each code maps to (unchanged from the pre-seam routes). */
|
||||
const STATUS: Record<UserFsErrorCode, number> = {
|
||||
bad_path: 400,
|
||||
bad_name: 400,
|
||||
not_found: 404,
|
||||
exists: 409,
|
||||
parent_missing: 404,
|
||||
not_a_folder: 400,
|
||||
not_a_file: 400,
|
||||
too_large: 413,
|
||||
unsupported: 501,
|
||||
}
|
||||
|
||||
/**
|
||||
* A filesystem failure already reduced to its wire form. Routes rethrow it as
|
||||
* `reply.code(err.status).send({ error: err.code })` without inspecting errno,
|
||||
* which is what lets the k8s implementation rebuild it from a sidecar response.
|
||||
*/
|
||||
export class UserFsError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(readonly code: UserFsErrorCode) {
|
||||
super(code)
|
||||
this.name = 'UserFsError'
|
||||
this.status = STATUS[code]
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `code` is one this seam knows how to represent. */
|
||||
export function isUserFsErrorCode(code: string): code is UserFsErrorCode {
|
||||
return code in STATUS
|
||||
}
|
||||
|
||||
/** Per-user filesystem operations, as the route layer needs them. */
|
||||
export interface UserFs {
|
||||
/** Create the user's home/workspace roots (`0700`). Idempotent.
|
||||
* `uid` (when provided) makes local mode chown the roots to the user's Linux
|
||||
* uid — the DSH child runs as that uid and would otherwise hit EACCES writing
|
||||
* `home/` (directories are created by the root control plane). */
|
||||
initUserRoot(userId: string, uid?: number): Promise<void>
|
||||
/** Absolute path of `relPath` **as the user's DSH process sees it** (pure path
|
||||
* math — the in-Pod mount path under k8s, the host path under local). */
|
||||
resolvePath(userId: string, relPath: string): string
|
||||
listDir(userId: string, relPath: string): Promise<FsEntry[]>
|
||||
mkdir(userId: string, relPath: string): Promise<void>
|
||||
/** Create a file or directory under `relPath`; returns the sanitized name. */
|
||||
createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string>
|
||||
/** Write `data` as `name` under `relPath`; returns the sanitized name. */
|
||||
upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string>
|
||||
/** Whether `relPath` is a directory; throws `not_found` when absent. */
|
||||
isDirectory(userId: string, relPath: string): Promise<boolean>
|
||||
/** Read a workspace-relative **file** (档案 56:供门户/实例页「我的文件」下载)。
|
||||
* 目录 → `not_a_file`;超过 `maxBytes` → `too_large`。
|
||||
* 注:k8s 实现目前抛 `unsupported`(sidecar 尚无 read 端点,属档案 19 §C8 未验证路径)。 */
|
||||
readFile(userId: string, relPath: string, maxBytes?: number): Promise<{ name: string; data: Buffer }>
|
||||
listInstalledPlugins(userId: string): Promise<PluginInfo[]>
|
||||
/** Write the post-restart command handoff the watchdog reads. */
|
||||
writeHandoff(userId: string, content: string): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Per-user filesystem layout + low-level helpers. This module is the single
|
||||
* place that knows the `users/<id>/{home,ws}` shape — the control plane, the
|
||||
* local spawner, the k8s Pod spec, and the file sidecar all derive their paths
|
||||
* from here so the four never drift apart.
|
||||
*
|
||||
* Isolation is enforced by the caller (see fs-guard).
|
||||
* @module dshs/fs/workspace
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { lstat, readdir, stat } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { PathEscapeError } from '../web/middleware/fs-guard.js'
|
||||
|
||||
// Layout segment names. Exported so the k8s Pod spec can build the same layout
|
||||
// with POSIX separators (`join` would emit backslashes when the control plane
|
||||
// is developed/tested on Windows) without duplicating the literals.
|
||||
export const USERS_DIR = 'users'
|
||||
export const WORKSPACE_DIR = 'ws'
|
||||
export const HOME_DIR = 'home'
|
||||
export const HANDOFF_FILE = 'handoff.json'
|
||||
|
||||
/** A user's own data root (`<dataRoot>/users/<id>`). */
|
||||
export function userRoot(dataRoot: string, userId: string): string {
|
||||
return join(dataRoot, USERS_DIR, userId)
|
||||
}
|
||||
|
||||
/** The workspace (user-visible files) within a user root. */
|
||||
export function workspaceRoot(root: string): string {
|
||||
return join(root, WORKSPACE_DIR)
|
||||
}
|
||||
|
||||
/** DSH's own state (profiles/sessions/credentials) within a user root. */
|
||||
export function homeRoot(root: string): string {
|
||||
return join(root, HOME_DIR)
|
||||
}
|
||||
|
||||
/** The watchdog command handoff within a user root. */
|
||||
export function handoffPath(root: string): string {
|
||||
return join(root, HANDOFF_FILE)
|
||||
}
|
||||
|
||||
/** Create a directory (0700) if it does not exist. */
|
||||
export function ensureDir(path: string): void {
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject `abs` when any path component between `root` and `abs` (inclusive) is
|
||||
* a symbolic link. The lexical guard (`resolveWithinRoot`) cannot see links
|
||||
* planted INSIDE the workspace: an upload through `ws/link -> /etc` passes the
|
||||
* prefix check and lands outside the user's root with the caller's privileges.
|
||||
* The desktop surface never needs symlinks, so they are forbidden here; the
|
||||
* user's DSH process keeps its normal kernel view of links.
|
||||
*
|
||||
* Only components strictly below `root` are inspected — the data root itself
|
||||
* may legitimately sit behind a link. A missing (or not-a-directory) component
|
||||
* ends the walk: nothing can exist below it, and the caller's own open/stat
|
||||
* will surface that as its usual errno.
|
||||
*/
|
||||
export async function rejectSymlinkEscape(root: string, abs: string): Promise<void> {
|
||||
const rel = relative(root, abs)
|
||||
if (rel === '') return // abs IS the root
|
||||
if (rel.startsWith('..')) throw new PathEscapeError(abs, root)
|
||||
let current = root
|
||||
for (const segment of rel.split(sep)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
current = join(current, segment)
|
||||
try {
|
||||
const stats = await lstat(current)
|
||||
if (stats.isSymbolicLink()) throw new PathEscapeError(abs, root)
|
||||
} catch (err) {
|
||||
if (err instanceof PathEscapeError) throw err
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
// Missing/not-a-directory component: nothing below it can exist either.
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') return
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One filesystem entry as returned to the desktop. */
|
||||
export interface FsEntry {
|
||||
name: string
|
||||
type: 'file' | 'dir'
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
/** List the immediate children of a directory (async, non-blocking). */
|
||||
export async function listDir(absPath: string): Promise<FsEntry[]> {
|
||||
const entries = await readdir(absPath, { withFileTypes: true })
|
||||
const result: FsEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const st = await stat(join(absPath, entry.name))
|
||||
result.push({
|
||||
name: entry.name,
|
||||
type: st.isDirectory() ? 'dir' : 'file',
|
||||
size: st.isFile() ? st.size : 0,
|
||||
mtimeMs: st.mtimeMs,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user