From 521c86d730d63d25b5eb94595da1fdb0a5884d01 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 00:35:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20=E6=9C=AC=E5=9C=B0=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=8B=92=E7=BB=9D=20Alist=20=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E8=B7=AF=E5=BE=84,=E8=A1=A5=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E5=9C=A8=20Alist=20=E5=BA=93=E6=A0=B9=E4=B8=8A=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 虚拟前缀 /@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 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/organize-exec.ts | 2 +- .../services/organize-transfer.ts | 2 +- .../services/replacement.ts | 2 +- .../acquisition-organize/services/scrape.ts | 2 +- .../server/src/modules/file-manager/mutate.ts | 2 +- apps/server/src/modules/file-manager/probe.ts | 2 +- apps/server/src/modules/file-manager/trash.ts | 2 +- .../server/src/modules/file-manager/upload.ts | 2 +- apps/server/src/modules/subtitles/service.ts | 10 +- apps/server/src/storage/local-fs.ts | 61 +++++++++ apps/server/src/storage/transfer.ts | 2 +- apps/server/src/storage/vfs.ts | 2 +- .../test/storage/remote-path-guard.test.ts | 128 ++++++++++++++++++ docs/design/storage-alist.md | 12 +- packages/adapters/src/fs/copy.ts | 4 + packages/adapters/src/fs/errors.ts | 2 + packages/adapters/src/fs/fsops.ts | 19 +++ packages/adapters/src/fs/fsproxy.ts | 4 + packages/adapters/src/fs/index.ts | 1 + packages/adapters/src/fs/move.ts | 3 + packages/adapters/src/fs/remote-guard.test.ts | 97 +++++++++++++ packages/adapters/src/fs/remote-guard.ts | 21 +++ packages/adapters/src/fs/stat.ts | 2 + 23 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/storage/local-fs.ts create mode 100644 apps/server/test/storage/remote-path-guard.test.ts create mode 100644 packages/adapters/src/fs/remote-guard.test.ts create mode 100644 packages/adapters/src/fs/remote-guard.ts diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index c84ff802..153c7c30 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { mkdir, realpath, rmdir } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import { type CopyReceipt, @@ -13,6 +12,7 @@ import { import type { OrganizationRecord } from "@umt/contracts"; import { sql } from "@umt/db"; import { withAttemptToken } from "@umt/tasks"; +import { mkdir, realpath, rmdir } from "../../../storage/local-fs.ts"; import { involvesRemote, remoteTransfer } from "../../../storage/transfer.ts"; import { isRemotePath, vcheckRoot, vmkdirp, vrenameNoClobber, vstatSafe } from "../../../storage/vfs.ts"; import { closeDecisions } from "../../sources-identity/service/common.ts"; diff --git a/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts b/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts index 63219a33..a4593e11 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; -import { readdir, rmdir } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters"; import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain"; import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping"; +import { readdir, rmdir } from "../../../storage/local-fs.ts"; import { isRemotePath, vstatSafe as statSafe, diff --git a/apps/server/src/modules/acquisition-organize/services/replacement.ts b/apps/server/src/modules/acquisition-organize/services/replacement.ts index 2afc5fa1..1d16263f 100644 --- a/apps/server/src/modules/acquisition-organize/services/replacement.ts +++ b/apps/server/src/modules/acquisition-organize/services/replacement.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters"; import { sql } from "@umt/db"; import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain"; +import { mkdir, readdir, realpath, rm } from "../../../storage/local-fs.ts"; import { isRemotePath, vcheckRoot, diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 7a67f10b..feb4b43d 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -1,5 +1,4 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters"; import { @@ -24,6 +23,7 @@ import { seasonOfDirName, workNfo, } from "@umt/domain"; +import { mkdir, rename, rm, writeFile } from "../../../storage/local-fs.ts"; import { isRemotePath, vlstat as lstat, diff --git a/apps/server/src/modules/file-manager/mutate.ts b/apps/server/src/modules/file-manager/mutate.ts index 931d50cd..9eaeb5f8 100644 --- a/apps/server/src/modules/file-manager/mutate.ts +++ b/apps/server/src/modules/file-manager/mutate.ts @@ -1,8 +1,8 @@ -import { lstat, mkdir } from "node:fs/promises"; import { basename, dirname, extname, join } from "node:path"; import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts"; import { sanitizeSegment } from "@umt/domain"; import type { Principal } from "../../http/auth.ts"; +import { lstat, mkdir } from "../../storage/local-fs.ts"; import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { audit, filesChanged } from "./ledger.ts"; diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts index 35997881..432aecae 100644 --- a/apps/server/src/modules/file-manager/probe.ts +++ b/apps/server/src/modules/file-manager/probe.ts @@ -1,5 +1,4 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { ProbeResult } from "@umt/adapters"; import type { @@ -11,6 +10,7 @@ import type { } from "@umt/contracts"; import { sql } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; +import { mkdir, readFile, writeFile } from "../../storage/local-fs.ts"; import { vlstat } from "../../storage/vfs.ts"; import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts"; import { audit } from "./ledger.ts"; diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index e3d30633..2c043854 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { moveSafe } from "@umt/adapters"; import type { @@ -16,6 +15,7 @@ import type { } from "@umt/contracts"; import { sql, type TrashItemRow } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; +import { lstat, mkdir, readdir, realpath, rm } from "../../storage/local-fs.ts"; import { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts"; import { parseVPath, toVPath } from "../../storage/vpath.ts"; import { upsertDecision } from "../acquisition-organize/services/common.ts"; diff --git a/apps/server/src/modules/file-manager/upload.ts b/apps/server/src/modules/file-manager/upload.ts index 43f6eefb..2e112111 100644 --- a/apps/server/src/modules/file-manager/upload.ts +++ b/apps/server/src/modules/file-manager/upload.ts @@ -1,10 +1,10 @@ import { randomUUID } from "node:crypto"; -import { mkdir, open, rm } from "node:fs/promises"; import { join } from "node:path"; import { commitNoClobber, partialPathFor } from "@umt/adapters"; import type { FilesUploadQuery, FilesUploadResult } from "@umt/contracts"; import { sanitizeSegment } from "@umt/domain"; import type { Principal } from "../../http/auth.ts"; +import { mkdir, open, rm } from "../../storage/local-fs.ts"; import { relayDir, remote, remoteError, vexists } from "../../storage/vfs.ts"; import { errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { audit, filesChanged } from "./ledger.ts"; diff --git a/apps/server/src/modules/subtitles/service.ts b/apps/server/src/modules/subtitles/service.ts index 5f963f91..87c00e64 100644 --- a/apps/server/src/modules/subtitles/service.ts +++ b/apps/server/src/modules/subtitles/service.ts @@ -1,4 +1,3 @@ -import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { lifecycleKey, @@ -26,6 +25,8 @@ import type { Selectable } from "kysely"; import { dataPaths } from "../../config.ts"; import { ApiException } from "../../http/errors.ts"; import { runSandbox, SandboxError } from "../../sandbox/client.ts"; +import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "../../storage/local-fs.ts"; +import { isRemotePath } from "../../storage/vpath.ts"; import type { ServerContext } from "../types.ts"; // 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer): @@ -199,6 +200,11 @@ export async function applySubtitles( failed.push({ name: it.name, reason: "目标视频不在这部作品的媒体库里" }); continue; } + // Alist 上的媒体库:补字幕暂不支持(MP 的 Alist 能力里也没有),明确失败,不往本机写 + if (isRemotePath(t.rootPath)) { + failed.push({ name: it.name, reason: "目标视频在 Alist 存储上,暂不支持补字幕" }); + continue; + } const name = subtitleFileName(t.relativePath, it.language, it.ext as SubtitleExt, defaultLang); const rel = join(dirname(t.relativePath), name); if (planned.has(rel)) { @@ -322,6 +328,8 @@ export async function undoSubtitleWrite(ctx: ServerContext, id: string): Promise .select("local_path") .where("id", "=", row.library_root_id) .executeTakeFirstOrThrow(); + if (isRemotePath(root.local_path)) + throw new ApiException("precondition_failed", "字幕在 Alist 存储上,不能在这里撤销"); const abs = join(root.local_path, row.relative_path); if (row.backup_path && (await exists(row.backup_path))) { await copyFile(row.backup_path, `${abs}.umt-restore.tmp`); diff --git a/apps/server/src/storage/local-fs.ts b/apps/server/src/storage/local-fs.ts new file mode 100644 index 00000000..8e897fa8 --- /dev/null +++ b/apps/server/src/storage/local-fs.ts @@ -0,0 +1,61 @@ +import * as fsp from "node:fs/promises"; +import { assertLocalPaths } from "@umt/adapters"; + +// 本地写入口的远端路径守卫(server 侧):整理 / 刮削 / 字幕 / 回收站 / 上传里直接调 node:fs 写文件的地方都从这里取函数。 +// 参数是 Alist 虚拟路径(/@alist/…)就抛 FsError("REMOTE_PATH_ON_LOCAL_FS"),不在本机建出 /@alist 目录或写进文件。 +// 读类函数(stat / readdir / readFile …)原样转出:对虚拟路径只会 ENOENT。 + +export * from "node:fs/promises"; + +type P = Parameters[0]; +const s = (p: unknown) => (typeof p === "string" ? p : p instanceof URL ? p.pathname : undefined); + +export const mkdir: typeof fsp.mkdir = (async (p: P, o?: Parameters[1]) => { + assertLocalPaths("mkdir", s(p)); + return fsp.mkdir(p, o); +}) as typeof fsp.mkdir; + +export const writeFile: typeof fsp.writeFile = async (p, data, o) => { + assertLocalPaths("writeFile", s(p)); + return fsp.writeFile(p, data, o); +}; + +export const rename: typeof fsp.rename = async (a, b) => { + assertLocalPaths("rename", s(a), s(b)); + return fsp.rename(a, b); +}; + +export const copyFile: typeof fsp.copyFile = async (a, b, mode) => { + assertLocalPaths("copyFile", s(a), s(b)); + return fsp.copyFile(a, b, mode); +}; + +export const rm: typeof fsp.rm = async (p, o) => { + assertLocalPaths("rm", s(p)); + return fsp.rm(p, o); +}; + +export const rmdir: typeof fsp.rmdir = async (p, o) => { + assertLocalPaths("rmdir", s(p)); + return fsp.rmdir(p, o); +}; + +export const unlink: typeof fsp.unlink = async (p) => { + assertLocalPaths("unlink", s(p)); + return fsp.unlink(p); +}; + +export const link: typeof fsp.link = async (a, b) => { + assertLocalPaths("link", s(a), s(b)); + return fsp.link(a, b); +}; + +/** open:写 / 追加 / 新建模式才拦;只读打开放行(虚拟路径只会 ENOENT) */ +export const open: typeof fsp.open = (async ( + p: Parameters[0], + flags?: string | number, + mode?: number, +) => { + if (flags !== undefined && flags !== "r") assertLocalPaths("open", s(p)); + return fsp.open(p, flags, mode); +}) as typeof fsp.open; diff --git a/apps/server/src/storage/transfer.ts b/apps/server/src/storage/transfer.ts index 021e29ab..c7762430 100644 --- a/apps/server/src/storage/transfer.ts +++ b/apps/server/src/storage/transfer.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { mkdir, rename, rm, stat, unlink } from "node:fs/promises"; import { basename, dirname, join, posix } from "node:path"; import { AlistError, type ProgressCallback } from "@umt/adapters"; +import { mkdir, rename, rm, stat, unlink } from "./local-fs.ts"; import { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts"; import { parseVPath, sameStorage } from "./vpath.ts"; diff --git a/apps/server/src/storage/vfs.ts b/apps/server/src/storage/vfs.ts index 2221c5ac..3df8ab45 100644 --- a/apps/server/src/storage/vfs.ts +++ b/apps/server/src/storage/vfs.ts @@ -1,5 +1,4 @@ import { createHash, randomUUID } from "node:crypto"; -import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, posix } from "node:path"; import { @@ -12,6 +11,7 @@ import { type StatResult, statSafe, } from "@umt/adapters"; +import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "./local-fs.ts"; import { storageDataDir, storageEntry } from "./registry.ts"; import { parseVPath, toVPath, type VPath } from "./vpath.ts"; diff --git a/apps/server/test/storage/remote-path-guard.test.ts b/apps/server/test/storage/remote-path-guard.test.ts new file mode 100644 index 00000000..889b8c9d --- /dev/null +++ b/apps/server/test/storage/remote-path-guard.test.ts @@ -0,0 +1,128 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts"; +import * as lfs from "../../src/storage/local-fs.ts"; +import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; +import { DEV_KEY } from "../helpers.ts"; + +// server 侧本地写入口(整理 / 刮削 / 字幕 / 回收站 / 上传共用的 storage/local-fs.ts)对 Alist 虚拟路径一律拒绝, +// 本机不出现 /@alist;补字幕的目标在 Alist 媒体库上时明确失败(不支持),不往本机写。 + +const V = "/@alist/00000000-0000-4000-8000-0000000000ee/s1/lib"; +const code = async (p: Promise) => { + try { + await p; + } catch (e) { + return (e as { code?: string }).code; + } + return "no-error"; +}; + +describe("local-fs 写入口守卫", () => { + let dir: string; + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "umt-lfs-")); + await lfs.writeFile(join(dir, "a.txt"), "x"); + }); + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("mkdir / writeFile / rename / copyFile / rm / rmdir / unlink / link / open(写)都拒绝虚拟路径", async () => { + const R = "REMOTE_PATH_ON_LOCAL_FS"; + expect(await code(lfs.mkdir(`${V}/d`, { recursive: true }))).toBe(R); + expect(await code(lfs.writeFile(`${V}/a.nfo`, ""))).toBe(R); + expect(await code(lfs.rename(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R); + expect(await code(lfs.copyFile(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R); + expect(await code(lfs.rm(`${V}/a.txt`, { force: true }))).toBe(R); + expect(await code(lfs.rmdir(V))).toBe(R); + expect(await code(lfs.unlink(`${V}/a.txt`))).toBe(R); + expect(await code(lfs.link(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R); + expect(await code(lfs.open(`${V}/a.txt`, "w"))).toBe(R); + expect(existsSync("/@alist")).toBe(false); + expect(existsSync(join(dir, "a.txt"))).toBe(true); + }); + + it("本地路径与只读打开照常", async () => { + await lfs.mkdir(join(dir, "sub"), { recursive: true }); + await lfs.rename(join(dir, "a.txt"), join(dir, "sub", "a.txt")); + expect(await lfs.readFile(join(dir, "sub", "a.txt"), "utf8")).toBe("x"); + expect(await code(lfs.open(`${V}/a.txt`, "r"))).toBe("ENOENT"); + }); +}); + +const hasPg = await pgAvailable(); +describe.skipIf(!hasPg)("补字幕:目标在 Alist 媒体库上明确失败", () => { + let h: Harness; + let entityId: string; + let locationId: string; + beforeAll(async () => { + h = await createHarness({ extraModules: [createSubtitlesModule()] }); + const root = await h.db + .insertInto("library_root") + .values({ + name: "Alist 剧集", + media_kind: "mixed", + local_path: V, + management_mode: "managed", + sentinel_file: null, + }) + .returning("id") + .executeTakeFirstOrThrow(); + entityId = ( + await h.db + .insertInto("media_entity") + .values({ kind: "series", title: "远端剧", year: 2024 } as never) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const a = await h.db + .insertInto("media_asset") + .values({ media_entity_id: entityId, kind: "video", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + locationId = ( + await h.db + .insertInto("asset_location") + .values({ + media_asset_id: a.id, + location_kind: "library", + library_root_id: root.id, + relative_path: "远端剧 (2024)/Season 1/远端剧 - S01E01.mkv", + status: "available", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + }, 120_000); + afterAll(async () => h?.close()); + + it("写入返回失败原因,不写任何文件、不建资产", async () => { + const res = await h.app.request("/api/v1/subtitles/apply", { + method: "POST", + headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, + body: JSON.stringify({ + entityId, + items: [ + { + name: "a.srt", + ext: "srt", + language: "zh-Hans", + content: btoa("1\n00:00:01,000 --> 00:00:02,000\nhi\n"), + targetLocationId: locationId, + }, + ], + }), + }); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data.written).toEqual([]); + expect(body.data.failed).toEqual([{ name: "a.srt", reason: "目标视频在 Alist 存储上,暂不支持补字幕" }]); + expect(existsSync("/@alist")).toBe(false); + const subs = await h.db.selectFrom("media_asset").select("id").where("kind", "=", "subtitle").execute(); + expect(subs).toEqual([]); + }); +}); diff --git a/docs/design/storage-alist.md b/docs/design/storage-alist.md index 22fbe262..f9d5c012 100644 --- a/docs/design/storage-alist.md +++ b/docs/design/storage-alist.md @@ -242,13 +242,21 @@ ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 `.umt- | 健康 | `system/health.ts`(`storage_instance` 挂载项) | | 前端 | `components/settings/storages.tsx`、`instances.tsx`(库根选存储)、`components/files/*`(存储组、上传、显示路径) | -## 14. 与设计的偏离 +## 14. 本地写入守卫 + +虚拟前缀读到本地只会 ENOENT,但写类操作会在本机真建出 `/@alist/…`。所以本地写入口统一拒绝虚拟路径(`FsError("REMOTE_PATH_ON_LOCAL_FS")`,消息以 `remote_path_on_local_fs:` 开头,不落盘): + +- adapters:`fs/remote-guard.ts`,接在 `runFsOp`、`FsProxy.run`(父进程就拒绝,不起子进程)、`copyWithVerify`、`crossDeviceMove`、`moveSafe`、`commitNoClobber`、`sweepStalePartials`、`writeRootSentinel`。读类(stat / checkRoot / sha256 / match)不拦。 +- server:`storage/local-fs.ts`(`node:fs/promises` 的写函数换成带守卫的版本),整理执行、替换、刮削、原样搬运、回收站、改名 / 新建目录、上传、探测缓存、补字幕、vfs / transfer 的本地分支都从这里取。 +- 测试:`packages/adapters/src/fs/remote-guard.test.ts`、`apps/server/test/storage/remote-path-guard.test.ts`。 + +## 15. 与设计的偏离 - 远端目录监控放在 `source-monitor.ts` 的 `walkRemoteSource`,没有单独的 `storage/snapshot.ts`。 - 文件管理「下载」到浏览器仍守本地同一规则(NFO / 字幕 / 图片 / 文本 ≤ 20 MiB);大文件「下载到本地」用「复制到」本机媒体库(走整理执行与账本)。MP 允许浏览器下载任意文件。 - Alist 根的目录树与列表不做递归目录摘要(视频数),目录树子目录一律显示可展开,避免逐目录请求网盘。 - 存储根是 Alist 的 `/`(各挂载的上一级)时,回收站放在被删项所在挂载的 `.umt-trash`。 - 已整理的文件不能「移动到」非媒体库的 Alist 存储根(会脱离媒体库),只能复制或先从媒体库移除。 -- 补字幕(`subtitles`)只写本机媒体库;Alist 媒体库上的字幕写入不在本次范围(MP 的 Alist 能力里没有)。 +- 补字幕(`subtitles`)只写本机媒体库;目标在 Alist 媒体库上时明确失败(「目标视频在 Alist 存储上,暂不支持补字幕」),不往本机写(MP 的 Alist 能力里没有)。 - 库存对账(`reconcile`)对 Alist 库根逐个位置 `get`,受实例限速;没有按目录批量列。 - 迁移只用了 0090;0091 未使用。 diff --git a/packages/adapters/src/fs/copy.ts b/packages/adapters/src/fs/copy.ts index c6ec44e0..7dbfdc2c 100644 --- a/packages/adapters/src/fs/copy.ts +++ b/packages/adapters/src/fs/copy.ts @@ -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 { + 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 { + 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 { + assertLocalPaths("sweep", dir); const removed: string[] = []; const now = Date.now(); for (const name of await readdir(dir)) { diff --git a/packages/adapters/src/fs/errors.ts b/packages/adapters/src/fs/errors.ts index 5f8ccb80..a8e6deb9 100644 --- a/packages/adapters/src/fs/errors.ts +++ b/packages/adapters/src/fs/errors.ts @@ -10,6 +10,8 @@ export type FsErrorCode = | "ABORTED" /** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */ | "TIMEOUT" + /** 本地文件操作收到 Alist 虚拟路径(/@alist/…):调用方没按存储分派,拒绝而不是写错位置 */ + | "REMOTE_PATH_ON_LOCAL_FS" | "IO"; /** diff --git a/packages/adapters/src/fs/fsops.ts b/packages/adapters/src/fs/fsops.ts index 249c4dba..42088d8a 100644 --- a/packages/adapters/src/fs/fsops.ts +++ b/packages/adapters/src/fs/fsops.ts @@ -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 { + assertLocalPaths(req.op, ...writePathsOf(req)); const { signal, progress } = ctx; const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total }); switch (req.op) { diff --git a/packages/adapters/src/fs/fsproxy.ts b/packages/adapters/src/fs/fsproxy.ts index 6b428371..882483cd 100644 --- a/packages/adapters/src/fs/fsproxy.ts +++ b/packages/adapters/src/fs/fsproxy.ts @@ -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 { call.signal?.throwIfAborted(); + // 远端虚拟路径在父进程就拒绝,不起执行子进程 + assertLocalPaths(req.op, ...writePathsOf(req)); if (this.#mode === "subprocess") { try { return await this.#runSubprocess(req, call); diff --git a/packages/adapters/src/fs/index.ts b/packages/adapters/src/fs/index.ts index b97a0c30..2b7c97de 100644 --- a/packages/adapters/src/fs/index.ts +++ b/packages/adapters/src/fs/index.ts @@ -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"; diff --git a/packages/adapters/src/fs/move.ts b/packages/adapters/src/fs/move.ts index f8cc348b..f1bf307d 100644 --- a/packages/adapters/src/fs/move.ts +++ b/packages/adapters/src/fs/move.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 { + assertLocalPaths("move", src, dst); let s: Awaited>; try { s = await lstat(src); @@ -102,6 +104,7 @@ async function exists(p: string): Promise { * - 删源失败 → attention(不能报成功) */ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise { + assertLocalPaths("crossMove", src, dst); const method = opts.hash ?? "size"; const io = { ...(opts.signal ? { signal: opts.signal } : {}), diff --git a/packages/adapters/src/fs/remote-guard.test.ts b/packages/adapters/src/fs/remote-guard.test.ts new file mode 100644 index 00000000..17b74e9f --- /dev/null +++ b/packages/adapters/src/fs/remote-guard.test.ts @@ -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)) => { + 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); + }); +}); diff --git a/packages/adapters/src/fs/remote-guard.ts b/packages/adapters/src/fs/remote-guard.ts new file mode 100644 index 00000000..52efac04 --- /dev/null +++ b/packages/adapters/src/fs/remote-guard.ts @@ -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 }, + ); +} diff --git a/packages/adapters/src/fs/stat.ts b/packages/adapters/src/fs/stat.ts index aca62781..5add1372 100644 --- a/packages/adapters/src/fs/stat.ts +++ b/packages/adapters/src/fs/stat.ts @@ -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 { + assertLocalPaths("writeRootSentinel", root); await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, { flag: "wx", }).catch((e) => {