fix(storage): 本地写入口拒绝 Alist 虚拟路径,补字幕在 Alist 库根上明确失败

虚拟前缀 /@alist/… 读到本地是 ENOENT,但 mkdir -p、复制 / 移动目标、写文件、回收站落点会在本机真建出
/@alist 并写入文件。统一守卫:任何本地写类操作参数是虚拟路径即抛 REMOTE_PATH_ON_LOCAL_FS
(remote_path_on_local_fs),不落盘。

- adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程拒绝不起子进程)、copyWithVerify、
  crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel
- server:storage/local-fs.ts 带守卫的写函数,整理 / 替换 / 刮削 / 回收站 / 上传 / 字幕 / vfs 本地分支改用它
- 补字幕目标在 Alist 媒体库上返回失败原因,撤销同样拒绝

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 00:35:41 +08:00
1 parent a54905e571
commit 521c86d730
23 files changed
+371 -13

No files matched your search

+4
View File
@@ -7,6 +7,7 @@ import { basename, dirname, join } from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { errnoOf, FsError, toFsError } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export type VerifyMethod = "size" | "sha256" | "sample";
@@ -231,6 +232,7 @@ const LINK_UNSUPPORTED = ["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"];
/** no-clobber 提交:link(tmp,dst) 在目标存在时原子失败;文件系统不支持硬链接时退回 lstat+rename。 */
export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
assertLocalPaths("commitNoClobber", tmp, dst);
try {
await link(tmp, dst);
await unlink(tmp);
@@ -334,6 +336,7 @@ async function replaceCommit(
* 用源文件权限覆盖会清掉继承 ACL(与 MP _copy_with_target_permissions 同理)。不要补 chmod/chown。
*/
export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> {
assertLocalPaths("copy", src, dst, opts.replaceOld?.trashDir);
const started = Date.now();
const method = opts.hash ?? "size";
const onExisting = opts.onExisting ?? "conflict";
@@ -503,6 +506,7 @@ export async function filesMatch(
/** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */
export async function sweepStalePartials(dir: string, olderThanMs: number, suffix = ".uhd-partial"): Promise<string[]> {
assertLocalPaths("sweep", dir);
const removed: string[] = [];
const now = Date.now();
for (const name of await readdir(dir)) {
+2
View File
@@ -10,6 +10,8 @@ export type FsErrorCode =
| "ABORTED"
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
| "TIMEOUT"
/** 本地文件操作收到 Alist 虚拟路径(/@alist/…):调用方没按存储分派,拒绝而不是写错位置 */
| "REMOTE_PATH_ON_LOCAL_FS"
| "IO";
/**
+19
View File
@@ -5,6 +5,7 @@ import { unlink } from "node:fs/promises";
import { type CopyOptions, copyWithVerify, filesMatchSha256, sha256Full, sweepStalePartials } from "./copy.ts";
import { FsError, toFsError } from "./errors.ts";
import { type CrossMoveOptions, crossDeviceMove, moveSafe } from "./move.ts";
import { assertLocalPaths } from "./remote-guard.ts";
import { checkRoot, statSafe } from "./stat.ts";
/** 可序列化的复制选项(去掉信号与回调) */
@@ -45,7 +46,25 @@ export interface FsOpContext {
progress: (p: FsProgress) => void;
}
/** 请求里会被写入 / 删除的路径(远端虚拟路径守卫用;读类操作对它只会 ENOENT,不拦) */
export function writePathsOf(req: FsOpRequest): string[] {
switch (req.op) {
case "copy":
return [req.src, req.dst, ...(req.opts?.replaceOld?.trashDir ? [req.opts.replaceOld.trashDir] : [])];
case "crossMove":
case "move":
return [req.src, req.dst];
case "sweep":
return [req.dir];
case "unlink":
return [req.path];
default:
return [];
}
}
export async function runFsOp(req: FsOpRequest, ctx: FsOpContext): Promise<unknown> {
assertLocalPaths(req.op, ...writePathsOf(req));
const { signal, progress } = ctx;
const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total });
switch (req.op) {
+4
View File
@@ -23,8 +23,10 @@ import {
runFsOp,
type WireCopyOptions,
type WireCrossMoveOptions,
writePathsOf,
} from "./fsops.ts";
import type { CrossMoveResult, MoveResult } from "./move.ts";
import { assertLocalPaths } from "./remote-guard.ts";
import type { RootHealth, StatResult } from "./stat.ts";
export type { FsProgress } from "./fsops.ts";
@@ -243,6 +245,8 @@ export class FsProxy {
async run(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> {
call.signal?.throwIfAborted();
// 远端虚拟路径在父进程就拒绝,不起执行子进程
assertLocalPaths(req.op, ...writePathsOf(req));
if (this.#mode === "subprocess") {
try {
return await this.#runSubprocess(req, call);
+1
View File
@@ -4,5 +4,6 @@ export * from "./ffprobe.ts";
export * from "./fsops.ts";
export * from "./fsproxy.ts";
export * from "./move.ts";
export * from "./remote-guard.ts";
export * from "./scan.ts";
export * from "./stat.ts";
+3
View File
@@ -4,6 +4,7 @@ import { link, lstat, rename, stat, unlink } from "node:fs/promises";
import { dirname } from "node:path";
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts";
import { errnoOf, FsError, toFsError } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
@@ -13,6 +14,7 @@ export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "
* 其他错误抛 FsError。
*/
export async function moveSafe(src: string, dst: string): Promise<MoveResult> {
assertLocalPaths("move", src, dst);
let s: Awaited<ReturnType<typeof lstat>>;
try {
s = await lstat(src);
@@ -102,6 +104,7 @@ async function exists(p: string): Promise<boolean> {
* - 删源失败 → attention(不能报成功)
*/
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
assertLocalPaths("crossMove", src, dst);
const method = opts.hash ?? "size";
const io = {
...(opts.signal ? { signal: opts.signal } : {}),
@@ -0,0 +1,97 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { commitNoClobber, copyWithVerify, sweepStalePartials } from "./copy.ts";
import { FsError, fsErrorClass } from "./errors.ts";
import { type FsOpRequest, runFsOp } from "./fsops.ts";
import { FsProxy } from "./fsproxy.ts";
import { crossDeviceMove, moveSafe } from "./move.ts";
import { assertLocalPaths, isVirtualRemotePath } from "./remote-guard.ts";
import { writeRootSentinel } from "./stat.ts";
// 远端虚拟路径守卫:Alist 路径(/@alist/…)传到本地写类操作一律抛 REMOTE_PATH_ON_LOCAL_FS,本机不能出现 /@alist。
const V = "/@alist/00000000-0000-4000-8000-0000000000ff/s1/lib";
let dir: string;
let src: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "umt-guard-"));
src = join(dir, "a.mkv");
await writeFile(src, "video");
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
const rejects = async (p: Promise<unknown> | (() => unknown)) => {
let err: unknown = null;
try {
await (typeof p === "function" ? p() : p);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(FsError);
expect((err as FsError).code).toBe("REMOTE_PATH_ON_LOCAL_FS");
expect((err as Error).message).toContain("remote_path_on_local_fs");
expect(existsSync("/@alist")).toBe(false);
expect(existsSync(src)).toBe(true);
};
describe("远端虚拟路径守卫", () => {
it("识别与分类", () => {
expect(isVirtualRemotePath(V)).toBe(true);
expect(isVirtualRemotePath("/@alist")).toBe(true);
expect(isVirtualRemotePath("/@alistx/a")).toBe(false);
expect(isVirtualRemotePath("/media/@alist/a")).toBe(false);
expect(fsErrorClass("REMOTE_PATH_ON_LOCAL_FS")).toBe("manual");
expect(() => assertLocalPaths("x", "/tmp/a", null, undefined)).not.toThrow();
});
it("各写类函数:目标或源是虚拟路径都拒绝,不落盘", async () => {
await rejects(copyWithVerify(src, `${V}/a.mkv`));
await rejects(copyWithVerify(`${V}/a.mkv`, join(dir, "b.mkv")));
await rejects(
copyWithVerify(src, join(dir, "c.mkv"), {
onExisting: "replace",
replaceOld: { mode: "trash", trashDir: `${V}/.umt-trash` },
}),
);
await rejects(crossDeviceMove(src, `${V}/a.mkv`));
await rejects(moveSafe(src, `${V}/a.mkv`));
await rejects(commitNoClobber(src, `${V}/a.mkv`));
await rejects(sweepStalePartials(V, 0));
await rejects(writeRootSentinel(V));
expect(existsSync(join(dir, "b.mkv"))).toBe(false);
});
const ctx = { signal: new AbortController().signal, progress: () => {} };
const reqs: FsOpRequest[] = [
{ op: "copy", src: "/tmp/x", dst: `${V}/a.mkv` },
{ op: "crossMove", src: "/tmp/x", dst: `${V}/a.mkv` },
{ op: "move", src: `${V}/a.mkv`, dst: "/tmp/y" },
{ op: "sweep", dir: V, olderThanMs: 0 },
{ op: "unlink", path: `${V}/a.mkv` },
];
it.each(reqs.map((r) => [r.op, r] as const))("runFsOp %s:拒绝", async (_op, req) => {
await rejects(runFsOp(req, ctx));
});
it("FsProxy.run(子进程与进程内两种模式):在父进程就拒绝,不起子进程", async () => {
for (const mode of ["subprocess", "inprocess"] as const) {
const px = new FsProxy({ mode });
for (const req of reqs) await rejects(px.run(req));
await rejects(px.copy(src, `${V}/a.mkv`));
await rejects(px.moveSafe(src, `${V}/a.mkv`));
await rejects(px.sweepStalePartials(V, 0));
expect(px.stats().spawned).toBe(0);
}
});
it("读类操作不拦(虚拟路径只会是不存在)", async () => {
const px = new FsProxy({ mode: "inprocess" });
expect(await px.statSafe(`${V}/a.mkv`)).toEqual({ kind: "missing" });
expect(existsSync("/@alist")).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { FsError } from "./errors.ts";
// 远端虚拟路径守卫(docs/design/storage-alist.md §0):Alist 路径在本系统里写成 /@alist/<存储 id>/<路径>。
// 本地 fs 读它只会 ENOENT,但写类操作(mkdir -p、复制 / 移动目标、写文件、回收站落点)会在本机真建出
// /@alist/... 并把文件写进去——静默写错位置。所有本地写入口先过这里:参数是远端路径就抛
// REMOTE_PATH_ON_LOCAL_FS,不落盘。
export const REMOTE_PATH_PREFIX = "/@alist";
export const isVirtualRemotePath = (p: unknown): boolean =>
typeof p === "string" && (p === REMOTE_PATH_PREFIX || p.startsWith(`${REMOTE_PATH_PREFIX}/`));
/** 任一路径是远端虚拟路径 → 抛 FsError("REMOTE_PATH_ON_LOCAL_FS") */
export function assertLocalPaths(op: string, ...paths: (string | null | undefined)[]): void {
const hit = paths.find(isVirtualRemotePath);
if (hit)
throw new FsError(
"REMOTE_PATH_ON_LOCAL_FS",
`remote_path_on_local_fs: 本地文件操作 ${op} 收到 Alist 路径,已拒绝:${hit}`,
{ path: hit },
);
}
+2
View File
@@ -2,6 +2,7 @@
import { lstat, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { errnoOf } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export const DEFAULT_ROOT_SENTINEL = ".uhd-root";
@@ -57,6 +58,7 @@ export async function checkRoot(root: string, sentinel: string = DEFAULT_ROOT_SE
/** 在根目录写入哨兵文件(首次登记库根时调用)。 */
export async function writeRootSentinel(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<void> {
assertLocalPaths("writeRootSentinel", root);
await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, {
flag: "wx",
}).catch((e) => {