初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user