虚拟前缀 /@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
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|