fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards

S-1 (/files/move, /files/organize action=move):
- organize-guard: authorizeMoveEntities checks every source's media entities
  (declared entity plus entities of registered assets under the source);
  one-time approval still covers the whole request; any source that cannot
  be attributed to an entity only passes with an approval; denial lists every
  unauthorized item; optional non-consuming pre-check
- transfer: entities per source (directory = registered assets beneath it)
- organize: all move groups pre-checked before any group executes; any
  denial -> whole request 403 approval_required
S-2:
- api.ts: GET /files/download and POST /files/trash/purge marked
  auth:"session"; files routes attach requireAdminSession from the registry
  annotation; handler-side purge check removed, download keeps a same-rule
  defense check; permanent delete uses isAdminSession
- DEV key rule written down: equals an admin-scope key for session-only
  endpoints, still admin for Move/delete approval bypass (as authorizeMove)
- api-registry.test: session list updated; new reverse assertion that no
  non-session endpoint answers 403 to an admin-scope bearer
S-5 (trash restore):
- restore-to requires a writable managed library root (isWritableTarget)
- in-place restore rebuilds missing parents level by level without
  following symlinks (mkdirWithin), realpath-checked per level
- cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume
  copies (trash in / restore out) go through fsproxy
Tests: multi-entity move 403 + listing, unattributed source, organize foreign
asset 403, download/purge 403 for DEV and admin-scope keys, restore target
not writable, symlinked parent, rebuild parents, cross-volume cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 00:45:56 +08:00
1 parent 9887d8b311
commit 17cf3cbf97
16 files changed
+587 -107

No files matched your search

+112 -3
View File
@@ -1,12 +1,15 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { findCrossDeviceBase } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
ADMIN_KEY,
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
SESSION,
seedAsset,
seedBinding,
seedDownload,
@@ -155,7 +158,10 @@ describe.skipIf(skip)("files 删除与回收站", () => {
.execute();
const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY);
expect(denied.status).toBe(403);
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() });
// 验收 07 S-2:DEV key 与 admin scope Key 同样 403,只认管理员会话
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() })).status).toBe(403);
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, ADMIN_KEY)).status).toBe(403);
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION);
expect(r.status).toBe(200);
expect(r.body.data.purged).toEqual([oldId]);
expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false);
@@ -212,3 +218,106 @@ describe.skipIf(skip)("files 删除与回收站", () => {
expect(await exists(join(h.lib, "keep.txt"))).toBe(true);
});
});
// 验收 07 S-5:还原到… 的目标根可写检查;原位还原逐级建目录不跟随符号链接;跨卷还原受上限、经 fsproxy。
const xdev = findCrossDeviceBase();
describe.skipIf(skip)("files 回收站还原守卫", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("trash5", { options: { trashCrossMaxBytes: 10 } });
});
afterAll(async () => h?.close());
const trashOne = async (rel: string, content: string) => {
await mkdir(join(h.lib, dirname(rel)), { recursive: true });
await writeFile(join(h.lib, rel), content);
const d = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: rel }],
dryRun: false,
});
expect(d.status).toBe(200);
return d.body.data.trashed[0].id as string;
};
it("还原到下载根 / register_only 库根 → 403 not_writable,回收站条目不动", async () => {
const id = await trashOne("ro/a.txt", "a");
await mkdir(join(h.dl, "in"), { recursive: true });
const toDl = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [id],
target: { root: h.dlRoot, path: "in" },
});
expect(toDl.status).toBe(403);
expect(toDl.body.error.message).toMatch(/^not_writable:/);
const roDir = await mkdtemp(join(h.base, "ro-lib-"));
await writeFile(join(roDir, ".uhd-root"), "sentinel\n");
const ro = await h.db
.insertInto("library_root")
.values({ name: "只登记库", media_kind: "mixed", local_path: roDir, management_mode: "register_only" })
.returning("id")
.executeTakeFirstOrThrow();
const toRo = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [id],
target: { root: `lib:${ro.id}`, path: "" },
});
expect(toRo.status).toBe(403);
expect(await exists(join(roDir, "a.txt"))).toBe(false);
const left = await h.db.selectFrom("trash_item").select("id").where("id", "=", id).execute();
expect(left).toHaveLength(1);
});
it("原位还原:父目录被换成指向根外的符号链接 → path_escape,根外不会被建出目录", async () => {
const id = await trashOne("Esc/sub/file.txt", "f");
await rm(join(h.lib, "Esc"), { recursive: true, force: true });
await symlink(h.outside, join(h.lib, "Esc"));
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
expect(r.status).toBe(200);
expect(r.body.data.restored).toEqual([]);
expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "path_escape" })]);
expect(await exists(join(h.outside, "sub"))).toBe(false);
await rm(join(h.lib, "Esc"));
});
it("原位还原:父目录不存在时在根内逐级重建", async () => {
const id = await trashOne("Deep/er/x.txt", "x");
await rm(join(h.lib, "Deep"), { recursive: true, force: true });
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
expect(r.body.data.restored).toEqual([{ id, root: h.libRoot, path: "Deep/er/x.txt" }]);
expect(await readFile(join(h.lib, "Deep", "er", "x.txt"), "utf8")).toBe("x");
});
it.skipIf(!xdev)(
"跨卷还原:超过 FILES_TRASH_CROSS_MAX_BYTES → too_large 不动;小文件经 fsproxy 复制后核验",
async () => {
const xlib = await mkdtemp(join(xdev!, "fm-xrestore-"));
try {
await writeFile(join(xlib, ".uhd-root"), "sentinel\n");
const x = await h.db
.insertInto("library_root")
.values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const big = await trashOne("xv/big.txt", "0123456789ABCDEF");
const small = await trashOne("xv/small.txt", "abc");
const r = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [big, small],
target: { root: `lib:${x.id}`, path: "" },
});
expect(r.status).toBe(200);
expect(r.body.data.failed).toEqual([expect.objectContaining({ id: big, code: "too_large" })]);
expect(r.body.data.restored).toEqual([{ id: small, root: `lib:${x.id}`, path: "small.txt" }]);
expect(await readFile(join(xlib, "small.txt"), "utf8")).toBe("abc");
expect(await exists(join(xlib, "big.txt"))).toBe(false);
const left = await h.db.selectFrom("trash_item").select("id").execute();
expect(left.map((l) => l.id)).toContain(big);
expect(left.map((l) => l.id)).not.toContain(small);
} finally {
await rm(xlib, { recursive: true, force: true });
}
},
);
});