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

@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir, realpath, rmdir } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path"; import { basename, dirname, join, relative } from "node:path";
import { import {
type CopyReceipt, type CopyReceipt,
@@ -13,6 +12,7 @@ import {
import type { OrganizationRecord } from "@umt/contracts"; import type { OrganizationRecord } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { withAttemptToken } from "@umt/tasks"; import { withAttemptToken } from "@umt/tasks";
import { mkdir, realpath, rmdir } from "../../../storage/local-fs.ts";
import { involvesRemote, remoteTransfer } from "../../../storage/transfer.ts"; import { involvesRemote, remoteTransfer } from "../../../storage/transfer.ts";
import { isRemotePath, vcheckRoot, vmkdirp, vrenameNoClobber, vstatSafe } from "../../../storage/vfs.ts"; import { isRemotePath, vcheckRoot, vmkdirp, vrenameNoClobber, vstatSafe } from "../../../storage/vfs.ts";
import { closeDecisions } from "../../sources-identity/service/common.ts"; import { closeDecisions } from "../../sources-identity/service/common.ts";
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readdir, rmdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path"; import { dirname, join, relative } from "node:path";
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters"; import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain"; import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping"; import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping";
import { readdir, rmdir } from "../../../storage/local-fs.ts";
import { import {
isRemotePath, isRemotePath,
vstatSafe as statSafe, vstatSafe as statSafe,
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir, readdir, realpath, rm } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path"; import { basename, dirname, join, relative } from "node:path";
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters"; import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain"; import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
import { mkdir, readdir, realpath, rm } from "../../../storage/local-fs.ts";
import { import {
isRemotePath, isRemotePath,
vcheckRoot, vcheckRoot,
@@ -1,5 +1,4 @@
import { createHash, randomUUID } from "node:crypto"; 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 { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters"; import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import { import {
@@ -24,6 +23,7 @@ import {
seasonOfDirName, seasonOfDirName,
workNfo, workNfo,
} from "@umt/domain"; } from "@umt/domain";
import { mkdir, rename, rm, writeFile } from "../../../storage/local-fs.ts";
import { import {
isRemotePath, isRemotePath,
vlstat as lstat, vlstat as lstat,
@@ -1,8 +1,8 @@
import { lstat, mkdir } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path"; import { basename, dirname, extname, join } from "node:path";
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts"; import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain"; import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts"; 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 { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts";
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts"; import { audit, filesChanged } from "./ledger.ts";
@@ -1,5 +1,4 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { ProbeResult } from "@umt/adapters"; import type { ProbeResult } from "@umt/adapters";
import type { import type {
@@ -11,6 +10,7 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { mkdir, readFile, writeFile } from "../../storage/local-fs.ts";
import { vlstat } from "../../storage/vfs.ts"; import { vlstat } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts"; import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts"; import { audit } from "./ledger.ts";
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { moveSafe } from "@umt/adapters"; import { moveSafe } from "@umt/adapters";
import type { import type {
@@ -16,6 +15,7 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db"; import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; 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 { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts";
import { parseVPath, toVPath } from "../../storage/vpath.ts"; import { parseVPath, toVPath } from "../../storage/vpath.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts"; import { upsertDecision } from "../acquisition-organize/services/common.ts";
@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir, open, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { commitNoClobber, partialPathFor } from "@umt/adapters"; import { commitNoClobber, partialPathFor } from "@umt/adapters";
import type { FilesUploadQuery, FilesUploadResult } from "@umt/contracts"; import type { FilesUploadQuery, FilesUploadResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain"; import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts"; 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 { relayDir, remote, remoteError, vexists } from "../../storage/vfs.ts";
import { errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts"; import { audit, filesChanged } from "./ledger.ts";
+9 -1
View File
@@ -1,4 +1,3 @@
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { import {
lifecycleKey, lifecycleKey,
@@ -26,6 +25,8 @@ import type { Selectable } from "kysely";
import { dataPaths } from "../../config.ts"; import { dataPaths } from "../../config.ts";
import { ApiException } from "../../http/errors.ts"; import { ApiException } from "../../http/errors.ts";
import { runSandbox, SandboxError } from "../../sandbox/client.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"; import type { ServerContext } from "../types.ts";
// 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer): // 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer):
@@ -199,6 +200,11 @@ export async function applySubtitles(
failed.push({ name: it.name, reason: "目标视频不在这部作品的媒体库里" }); failed.push({ name: it.name, reason: "目标视频不在这部作品的媒体库里" });
continue; 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 name = subtitleFileName(t.relativePath, it.language, it.ext as SubtitleExt, defaultLang);
const rel = join(dirname(t.relativePath), name); const rel = join(dirname(t.relativePath), name);
if (planned.has(rel)) { if (planned.has(rel)) {
@@ -322,6 +328,8 @@ export async function undoSubtitleWrite(ctx: ServerContext, id: string): Promise
.select("local_path") .select("local_path")
.where("id", "=", row.library_root_id) .where("id", "=", row.library_root_id)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
if (isRemotePath(root.local_path))
throw new ApiException("precondition_failed", "字幕在 Alist 存储上,不能在这里撤销");
const abs = join(root.local_path, row.relative_path); const abs = join(root.local_path, row.relative_path);
if (row.backup_path && (await exists(row.backup_path))) { if (row.backup_path && (await exists(row.backup_path))) {
await copyFile(row.backup_path, `${abs}.umt-restore.tmp`); await copyFile(row.backup_path, `${abs}.umt-restore.tmp`);
+61
View File
@@ -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<typeof fsp.mkdir>[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<typeof fsp.mkdir>[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<typeof fsp.open>[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;
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir, rename, rm, stat, unlink } from "node:fs/promises";
import { basename, dirname, join, posix } from "node:path"; import { basename, dirname, join, posix } from "node:path";
import { AlistError, type ProgressCallback } from "@umt/adapters"; 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 { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts";
import { parseVPath, sameStorage } from "./vpath.ts"; import { parseVPath, sameStorage } from "./vpath.ts";
+1 -1
View File
@@ -1,5 +1,4 @@
import { createHash, randomUUID } from "node:crypto"; 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 { tmpdir } from "node:os";
import { basename, dirname, join, posix } from "node:path"; import { basename, dirname, join, posix } from "node:path";
import { import {
@@ -12,6 +11,7 @@ import {
type StatResult, type StatResult,
statSafe, statSafe,
} from "@umt/adapters"; } from "@umt/adapters";
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "./local-fs.ts";
import { storageDataDir, storageEntry } from "./registry.ts"; import { storageDataDir, storageEntry } from "./registry.ts";
import { parseVPath, toVPath, type VPath } from "./vpath.ts"; import { parseVPath, toVPath, type VPath } from "./vpath.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<unknown>) => {
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`, "<movie/>"))).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([]);
});
});
+10 -2
View File
@@ -242,13 +242,21 @@ ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 `.umt-
| 健康 | `system/health.ts`(`storage_instance` 挂载项) | | 健康 | `system/health.ts`(`storage_instance` 挂载项) |
| 前端 | `components/settings/storages.tsx`、`instances.tsx`(库根选存储)、`components/files/*`(存储组、上传、显示路径) | | 前端 | `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`。 - 远端目录监控放在 `source-monitor.ts` 的 `walkRemoteSource`,没有单独的 `storage/snapshot.ts`。
- 文件管理「下载」到浏览器仍守本地同一规则(NFO / 字幕 / 图片 / 文本 ≤ 20 MiB);大文件「下载到本地」用「复制到」本机媒体库(走整理执行与账本)。MP 允许浏览器下载任意文件。 - 文件管理「下载」到浏览器仍守本地同一规则(NFO / 字幕 / 图片 / 文本 ≤ 20 MiB);大文件「下载到本地」用「复制到」本机媒体库(走整理执行与账本)。MP 允许浏览器下载任意文件。
- Alist 根的目录树与列表不做递归目录摘要(视频数),目录树子目录一律显示可展开,避免逐目录请求网盘。 - Alist 根的目录树与列表不做递归目录摘要(视频数),目录树子目录一律显示可展开,避免逐目录请求网盘。
- 存储根是 Alist 的 `/`(各挂载的上一级)时,回收站放在被删项所在挂载的 `.umt-trash`。 - 存储根是 Alist 的 `/`(各挂载的上一级)时,回收站放在被删项所在挂载的 `.umt-trash`。
- 已整理的文件不能「移动到」非媒体库的 Alist 存储根(会脱离媒体库),只能复制或先从媒体库移除。 - 已整理的文件不能「移动到」非媒体库的 Alist 存储根(会脱离媒体库),只能复制或先从媒体库移除。
- 补字幕(`subtitles`)只写本机媒体库;Alist 媒体库上的字幕写入不在本次范围(MP 的 Alist 能力里没有)。 - 补字幕(`subtitles`)只写本机媒体库;目标在 Alist 媒体库上时明确失败(「目标视频在 Alist 存储上,暂不支持补字幕」),不往本机写(MP 的 Alist 能力里没有)。
- 库存对账(`reconcile`)对 Alist 库根逐个位置 `get`,受实例限速;没有按目录批量列。 - 库存对账(`reconcile`)对 Alist 库根逐个位置 `get`,受实例限速;没有按目录批量列。
- 迁移只用了 0090;0091 未使用。 - 迁移只用了 0090;0091 未使用。
+4
View File
@@ -7,6 +7,7 @@ import { basename, dirname, join } from "node:path";
import { Transform } from "node:stream"; import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { errnoOf, FsError, toFsError } from "./errors.ts"; import { errnoOf, FsError, toFsError } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export type VerifyMethod = "size" | "sha256" | "sample"; 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。 */ /** no-clobber 提交:link(tmp,dst) 在目标存在时原子失败;文件系统不支持硬链接时退回 lstat+rename。 */
export async function commitNoClobber(tmp: string, dst: string): Promise<void> { export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
assertLocalPaths("commitNoClobber", tmp, dst);
try { try {
await link(tmp, dst); await link(tmp, dst);
await unlink(tmp); await unlink(tmp);
@@ -334,6 +336,7 @@ async function replaceCommit(
* 用源文件权限覆盖会清掉继承 ACL(与 MP _copy_with_target_permissions 同理)。不要补 chmod/chown。 * 用源文件权限覆盖会清掉继承 ACL(与 MP _copy_with_target_permissions 同理)。不要补 chmod/chown。
*/ */
export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> { export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> {
assertLocalPaths("copy", src, dst, opts.replaceOld?.trashDir);
const started = Date.now(); const started = Date.now();
const method = opts.hash ?? "size"; const method = opts.hash ?? "size";
const onExisting = opts.onExisting ?? "conflict"; const onExisting = opts.onExisting ?? "conflict";
@@ -503,6 +506,7 @@ export async function filesMatch(
/** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */ /** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */
export async function sweepStalePartials(dir: string, olderThanMs: number, suffix = ".uhd-partial"): Promise<string[]> { export async function sweepStalePartials(dir: string, olderThanMs: number, suffix = ".uhd-partial"): Promise<string[]> {
assertLocalPaths("sweep", dir);
const removed: string[] = []; const removed: string[] = [];
const now = Date.now(); const now = Date.now();
for (const name of await readdir(dir)) { for (const name of await readdir(dir)) {
+2
View File
@@ -10,6 +10,8 @@ export type FsErrorCode =
| "ABORTED" | "ABORTED"
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */ /** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
| "TIMEOUT" | "TIMEOUT"
/** 本地文件操作收到 Alist 虚拟路径(/@alist/…):调用方没按存储分派,拒绝而不是写错位置 */
| "REMOTE_PATH_ON_LOCAL_FS"
| "IO"; | "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 { type CopyOptions, copyWithVerify, filesMatchSha256, sha256Full, sweepStalePartials } from "./copy.ts";
import { FsError, toFsError } from "./errors.ts"; import { FsError, toFsError } from "./errors.ts";
import { type CrossMoveOptions, crossDeviceMove, moveSafe } from "./move.ts"; import { type CrossMoveOptions, crossDeviceMove, moveSafe } from "./move.ts";
import { assertLocalPaths } from "./remote-guard.ts";
import { checkRoot, statSafe } from "./stat.ts"; import { checkRoot, statSafe } from "./stat.ts";
/** 可序列化的复制选项(去掉信号与回调) */ /** 可序列化的复制选项(去掉信号与回调) */
@@ -45,7 +46,25 @@ export interface FsOpContext {
progress: (p: FsProgress) => void; 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> { export async function runFsOp(req: FsOpRequest, ctx: FsOpContext): Promise<unknown> {
assertLocalPaths(req.op, ...writePathsOf(req));
const { signal, progress } = ctx; const { signal, progress } = ctx;
const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total }); const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total });
switch (req.op) { switch (req.op) {
+4
View File
@@ -23,8 +23,10 @@ import {
runFsOp, runFsOp,
type WireCopyOptions, type WireCopyOptions,
type WireCrossMoveOptions, type WireCrossMoveOptions,
writePathsOf,
} from "./fsops.ts"; } from "./fsops.ts";
import type { CrossMoveResult, MoveResult } from "./move.ts"; import type { CrossMoveResult, MoveResult } from "./move.ts";
import { assertLocalPaths } from "./remote-guard.ts";
import type { RootHealth, StatResult } from "./stat.ts"; import type { RootHealth, StatResult } from "./stat.ts";
export type { FsProgress } from "./fsops.ts"; export type { FsProgress } from "./fsops.ts";
@@ -243,6 +245,8 @@ export class FsProxy {
async run(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> { async run(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> {
call.signal?.throwIfAborted(); call.signal?.throwIfAborted();
// 远端虚拟路径在父进程就拒绝,不起执行子进程
assertLocalPaths(req.op, ...writePathsOf(req));
if (this.#mode === "subprocess") { if (this.#mode === "subprocess") {
try { try {
return await this.#runSubprocess(req, call); return await this.#runSubprocess(req, call);
+1
View File
@@ -4,5 +4,6 @@ export * from "./ffprobe.ts";
export * from "./fsops.ts"; export * from "./fsops.ts";
export * from "./fsproxy.ts"; export * from "./fsproxy.ts";
export * from "./move.ts"; export * from "./move.ts";
export * from "./remote-guard.ts";
export * from "./scan.ts"; export * from "./scan.ts";
export * from "./stat.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 { dirname } from "node:path";
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts"; import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts";
import { errnoOf, FsError, toFsError } from "./errors.ts"; import { errnoOf, FsError, toFsError } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" }; export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
@@ -13,6 +14,7 @@ export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "
* 其他错误抛 FsError。 * 其他错误抛 FsError。
*/ */
export async function moveSafe(src: string, dst: string): Promise<MoveResult> { export async function moveSafe(src: string, dst: string): Promise<MoveResult> {
assertLocalPaths("move", src, dst);
let s: Awaited<ReturnType<typeof lstat>>; let s: Awaited<ReturnType<typeof lstat>>;
try { try {
s = await lstat(src); s = await lstat(src);
@@ -102,6 +104,7 @@ async function exists(p: string): Promise<boolean> {
* - 删源失败 → attention(不能报成功) * - 删源失败 → attention(不能报成功)
*/ */
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> { export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
assertLocalPaths("crossMove", src, dst);
const method = opts.hash ?? "size"; const method = opts.hash ?? "size";
const io = { const io = {
...(opts.signal ? { signal: opts.signal } : {}), ...(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 { lstat, stat, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { errnoOf } from "./errors.ts"; import { errnoOf } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
export const DEFAULT_ROOT_SENTINEL = ".uhd-root"; 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> { 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`, { await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, {
flag: "wx", flag: "wx",
}).catch((e) => { }).catch((e) => {