Files
dsh_shenxian/test/local-user-fs.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

142 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LocalUserFs regression tests (node:test, against built lib/ — `npm test`
// builds first). Focus: the symlink-escape guard layered on top of lexical
// path containment. A link planted inside the workspace (`ws/link -> /etc`)
// passes the prefix check, so every operation must reject symlink components
// before touching the filesystem.
//
// Symlink creation is privilege-gated on Windows (junctions work unprivileged,
// file links need dev mode), so the link-based cases self-skip when the FS
// refuses to create one.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
import { UserFsError } from '../lib/fs/user-fs.js'
const USER = 'u1'
function makeUser(t) {
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-lufs-'))
t.after(() => rmSync(dataRoot, { recursive: true, force: true }))
const ws = join(dataRoot, 'users', USER, 'ws')
mkdirSync(ws, { recursive: true })
return { fs: new LocalUserFs((id) => join(dataRoot, 'users', id)), dataRoot, ws }
}
/** Create a link; false when the platform refuses (e.g. unprivileged Windows). */
function tryLink(target, path, type) {
try {
symlinkSync(target, path, type)
return true
} catch {
return false
}
}
function isBadPath(err) {
return err instanceof UserFsError && err.code === 'bad_path'
}
test('upload/mkdir/listDir work through nested directories', async (t) => {
const { fs } = makeUser(t)
await fs.mkdir(USER, 'proj')
assert.equal(await fs.upload(USER, 'proj', 'hello.txt', Buffer.from('hi')), 'hello.txt')
const entries = await fs.listDir(USER, 'proj')
assert.deepEqual(entries.map((e) => e.name).sort(), ['hello.txt'])
})
test('lexical traversal is still rejected with bad_path', async (t) => {
const { fs } = makeUser(t)
await assert.rejects(() => fs.upload(USER, '../outside', 'x.txt', Buffer.from('x')), isBadPath)
await assert.rejects(() => fs.listDir(USER, '../../etc'), isBadPath)
})
test('upload through an in-workspace directory symlink is rejected', async (t) => {
const { fs, dataRoot, ws } = makeUser(t)
const outside = join(dataRoot, 'outside')
mkdirSync(outside)
const type = process.platform === 'win32' ? 'junction' : 'dir'
if (!tryLink(outside, join(ws, 'link'), type)) {
t.skip('symlink creation unavailable on this host')
return
}
await assert.rejects(() => fs.upload(USER, 'link', 'escaped.txt', Buffer.from('x')), isBadPath)
await assert.rejects(() => fs.mkdir(USER, 'link/sub'), isBadPath)
await assert.rejects(() => fs.listDir(USER, 'link'), isBadPath)
await assert.rejects(() => fs.isDirectory(USER, 'link'), isBadPath)
})
test('upload onto an existing symlinked filename does not follow it', async (t) => {
const { fs, ws } = makeUser(t)
writeFileSync(join(ws, 'real.txt'), 'original')
if (!tryLink(join(ws, 'real.txt'), join(ws, 'alias.txt'), 'file')) {
t.skip('symlink creation unavailable on this host')
return
}
await assert.rejects(() => fs.upload(USER, '', 'alias.txt', Buffer.from('evil')), isBadPath)
assert.equal(readFileSync(join(ws, 'real.txt'), 'utf8'), 'original', 'target untouched')
})
test('missing path components end the walk and surface as not_found', async (t) => {
const { fs } = makeUser(t)
await assert.rejects(
() => fs.isDirectory(USER, 'ghost/deep'),
(err) => err instanceof UserFsError && err.code === 'not_found',
)
})
// ─── 档案 56:readFile(门户/实例页「我的文件」下载) ───────────────
test("readFile: 返回文件名与内容", async (t) => {
const { fs, ws } = makeUser(t)
writeFileSync(join(ws, "报告.md"), "# 内容\n")
const out = await fs.readFile(USER, "报告.md")
assert.equal(out.name, "报告.md")
assert.equal(out.data.toString("utf8"), "# 内容\n")
})
test("readFile: 目录→not_a_file;缺失→not_found;超限→too_large", async (t) => {
const { fs, ws } = makeUser(t)
mkdirSync(join(ws, "dir"))
await assert.rejects(() => fs.readFile(USER, "dir"), (e) => e instanceof UserFsError && e.code === "not_a_file")
await assert.rejects(() => fs.readFile(USER, "ghost.txt"), (e) => e instanceof UserFsError && e.code === "not_found")
writeFileSync(join(ws, "big.bin"), Buffer.alloc(4096))
await assert.rejects(() => fs.readFile(USER, "big.bin", 1024), (e) => e instanceof UserFsError && e.code === "too_large")
})
test("readFile: 路径逃逸被拒", async (t) => {
const { fs } = makeUser(t)
await assert.rejects(() => fs.readFile(USER, "../outside.txt"), isBadPath)
await assert.rejects(() => fs.readFile(USER, "../../etc/passwd"), isBadPath)
})
test("readFile: 符号链接逃逸被拒(不跟随工作区内的链接)", async (t) => {
const { fs, ws } = makeUser(t)
if (!tryLink("/etc/passwd", join(ws, "link.txt"), "file")) return
await assert.rejects(() => fs.readFile(USER, "link.txt"), isBadPath)
})
// ── 档案 138:home 下平台托管配置文件(跨机读写那一层的地基)────────────────────
// 这组判据存在的理由:落地层原先直接 `join(home_dir, name)` + 本机 fs ⇒ 对"实例在
// worker 上"的用户静默空操作(读回空串、写到自己盘的野路径)。改成走 UserFs 之后,
// 名字必须**只**能是那两个固定文件名,否则就等于给远端开了一个任意文件读写口。
test("home 文件:读写往返 + 不存在 ⇒ null(不是抛错)", async (t) => {
const { fs } = makeUser(t)
assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), null, "首次应为空")
await fs.writeHomeFile(USER, ".credentials.yaml", "version: 1\nrefs:\n A: 'b'\n")
assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), "version: 1\nrefs:\n A: 'b'\n")
// 两个白名单文件互不串(别把 settings 写到 credentials 上)
assert.equal(await fs.readHomeFile(USER, "settings.yaml"), null)
})
test("home 文件:⛔ 只收白名单里的裸文件名(路径/越界名一律 bad_path)", async (t) => {
const { fs } = makeUser(t)
for (const bad of ["../.credentials.yaml", "home/.credentials.yaml", "/etc/passwd", "id_rsa", "", "settings.yaml.bak"]) {
await assert.rejects(() => fs.readHomeFile(USER, bad), isBadPath, `读应拒:${bad}`)
await assert.rejects(() => fs.writeHomeFile(USER, bad, "x"), isBadPath, `写应拒:${bad}`)
}
})