diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index 3e0ff600f..872744730 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -1,3 +1,4 @@ +import os import shutil from pathlib import Path from typing import Optional, List @@ -195,11 +196,31 @@ class LocalStorage(StorageBase): """ return Path(fileitem.path) - def _copy_with_progress(self, src: Path, dest: Path): + @staticmethod + def _copy_with_target_permissions(src: Path, dest: Path) -> Path: + """ + 复制文件内容和时间戳,并保留目标目录赋予新文件的权限。 + + 目标目录的默认权限或继承 ACL 应作为媒体库的访问策略,复制完成后不能再用 + 源文件权限覆盖,否则部分文件系统会清除已继承的 ACL。 + + :param src: 源文件路径 + :param dest: 目标文件路径 + :return: 目标文件路径 + """ + src = Path(src) + dest = Path(dest) + src_stat = src.stat() + shutil.copyfile(src, dest) + os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns)) + return dest + + def _copy_with_progress(self, src: Path, dest: Path) -> bool: """ 分块复制文件并回调进度 """ - total_size = src.stat().st_size + src_stat = src.stat() + total_size = src_stat.st_size copied_size = 0 progress_callback = transfer_process(src.as_posix()) try: @@ -217,8 +238,7 @@ class LocalStorage(StorageBase): if progress_callback: percent = copied_size / total_size * 100 progress_callback(percent) - # 保留文件时间戳、权限等信息 - shutil.copystat(src, dest) + os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns)) return True except Exception as e: logger.error(f"【本地】复制文件 {src} 失败:{e}") @@ -273,11 +293,8 @@ class LocalStorage(StorageBase): if self._copy_with_progress(src, dest): return True else: - code, message = SystemUtils.copy(src, dest) - if code == 0: - return True - else: - logger.error(f"【本地】复制文件失败:{message}") + self._copy_with_target_permissions(src, dest) + return True except Exception as err: logger.error(f"【本地】复制文件失败:{err}") return False @@ -303,11 +320,8 @@ class LocalStorage(StorageBase): src.unlink() return True else: - code, message = SystemUtils.move(src, dest) - if code == 0: - return True - else: - logger.error(f"【本地】移动文件失败:{message}") + shutil.move(src, dest, copy_function=self._copy_with_target_permissions) + return True except Exception as err: logger.error(f"【本地】移动文件失败:{err}") return False diff --git a/tests/test_local_storage.py b/tests/test_local_storage.py new file mode 100644 index 000000000..25f8291cc --- /dev/null +++ b/tests/test_local_storage.py @@ -0,0 +1,153 @@ +import errno +import os +import shutil +from pathlib import Path +from unittest.mock import patch + +from app import schemas +from app.modules.filemanager.storages import local as local_storage_module + + +SOURCE_MTIME_NS = 1_000_000_000_000_000_000 + + +def _make_storage() -> local_storage_module.LocalStorage: + """绕过配置初始化,构造仅用于文件操作测试的本地存储实例。""" + return object.__new__(local_storage_module.LocalStorage) + + +def _default_file_mode(directory: Path) -> int: + """获取当前进程在目标目录中新建文件时的默认权限。""" + sentinel = directory / "mode-sentinel.bin" + sentinel.write_bytes(b"") + return sentinel.stat().st_mode & 0o777 + + +def _prepare_source(source: Path, target_mode: int) -> int: + """创建与目标默认权限不同的可读源文件,并设置固定修改时间。""" + source.write_bytes(b"moviepilot-acl-test") + source_mode = 0o600 if target_mode != 0o600 else 0o640 + source.chmod(source_mode) + os.utime(source, ns=(SOURCE_MTIME_NS, SOURCE_MTIME_NS)) + return source_mode + + +def _assert_copied_file(source_content: bytes, target: Path, target_mode: int) -> None: + """校验复制结果保留内容和时间戳,同时沿用目标目录权限。""" + assert target.stat().st_mode & 0o777 == target_mode + assert target.stat().st_mtime_ns == SOURCE_MTIME_NS + target.chmod(target_mode | 0o400) + assert target.read_bytes() == source_content + + +def test_copy_with_progress_keeps_target_permissions(tmp_path: Path) -> None: + """进度复制应保留时间戳,但不得用源权限覆盖目标目录赋予的权限。""" + source = tmp_path / "progress-source.bin" + target = tmp_path / "progress-target.bin" + target_mode = _default_file_mode(tmp_path) + source_mode = _prepare_source(source, target_mode) + source_content = source.read_bytes() + storage = _make_storage() + + with patch.object( + local_storage_module, + "transfer_process", + return_value=lambda *_args, **_kwargs: None, + ): + result = storage._copy_with_progress(source, target) + + assert result is True + assert target_mode != source_mode + _assert_copied_file(source_content, target, target_mode) + + +def test_copy_keeps_target_permissions(tmp_path: Path) -> None: + """普通复制应让新文件继承目标目录权限,并继续保留源文件时间戳。""" + source = tmp_path / "copy-source.bin" + target = tmp_path / "copy-target.bin" + target_mode = _default_file_mode(tmp_path) + source_mode = _prepare_source(source, target_mode) + source_content = source.read_bytes() + storage = _make_storage() + + with patch.object( + local_storage_module.LocalStorage, + "_LocalStorage__should_show_progress", + return_value=False, + ): + result = storage.copy( + schemas.FileItem(path=source.as_posix()), + tmp_path, + target.name, + ) + + assert result is True + assert target_mode != source_mode + _assert_copied_file(source_content, target, target_mode) + + +def test_cross_device_move_keeps_target_permissions(tmp_path: Path) -> None: + """跨盘移动降级为复制时应继承目标权限,成功后再删除源文件。""" + source = tmp_path / "cross-device-source.bin" + target = tmp_path / "cross-device-target.bin" + target_mode = _default_file_mode(tmp_path) + source_mode = _prepare_source(source, target_mode) + source_content = source.read_bytes() + storage = _make_storage() + + with ( + patch.object( + local_storage_module.LocalStorage, + "_LocalStorage__should_show_progress", + return_value=False, + ), + patch.object( + shutil.os, + "rename", + side_effect=OSError(errno.EXDEV, "跨设备移动"), + ), + ): + result = storage.move( + schemas.FileItem(path=source.as_posix()), + tmp_path, + target.name, + ) + + assert result is True + assert not source.exists() + assert target_mode != source_mode + _assert_copied_file(source_content, target, target_mode) + + +def test_same_device_move_still_uses_rename(tmp_path: Path) -> None: + """同盘移动应继续使用原子重命名,不复制文件或改变原有权限。""" + source = tmp_path / "same-device-source.bin" + target = tmp_path / "same-device-target.bin" + source.write_bytes(b"same-device") + source.chmod(0o600) + source_stat = source.stat() + storage = _make_storage() + + with ( + patch.object( + local_storage_module.LocalStorage, + "_LocalStorage__should_show_progress", + return_value=False, + ), + patch.object( + storage, + "_copy_with_target_permissions", + side_effect=AssertionError("同盘移动不应复制文件"), + ) as copy_mock, + ): + result = storage.move( + schemas.FileItem(path=source.as_posix()), + tmp_path, + target.name, + ) + + assert result is True + assert not source.exists() + assert target.stat().st_ino == source_stat.st_ino + assert target.stat().st_mode & 0o777 == 0o600 + copy_mock.assert_not_called()