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)) {