Files
dsh_ai1net_server/src/fs/local-user-fs.ts
T

180 lines
6.3 KiB
TypeScript

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