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:
1 parent
a54905e571
commit
521c86d730
23 files changed
+371
-13
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export type FsErrorCode =
|
||||
| "ABORTED"
|
||||
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
|
||||
| "TIMEOUT"
|
||||
/** 本地文件操作收到 Alist 虚拟路径(/@alist/…):调用方没按存储分派,拒绝而不是写错位置 */
|
||||
| "REMOTE_PATH_ON_LOCAL_FS"
|
||||
| "IO";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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) => {
|
||||
|
||||
Reference in new issue
Block a user