diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 933f14eab..b988b4340 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -803,24 +803,22 @@ class TransHandler: @staticmethod def __assert_file_identity(expected: FileItem, current: Optional[FileItem]) -> None: """ - 清理前比对冻结文件的大小、修改时间及可用身份;证据不足时保留文件。 + 清理前比对冻结文件的大小、类型及可用身份;证据不足时保留文件。 本地链接的查询沿用 stat 语义,硬链接和软链接均比较实际内容的身份。 - 旧快照没有 fileid 时允许用完整大小、时间凭据;缺少这两项则不能自动删除。 + 云盘修改时间可能在上传后变化或缺失,不参与校验;旧快照没有 fileid 时使用大小凭据。 - :param expected: 冻结的文件身份,必须包含大小和修改时间 + :param expected: 冻结的文件身份,必须包含大小 :param current: 当前严格查询结果;不可见时视为冲突 :raises TransferExecutionConflictError: 文件消失、身份字段变化或类型不一致 :raises StorageQueryError: 缺少必要身份凭据 """ if current is None: raise TransferExecutionConflictError(f"文件 {expected.path} 已不可见,保留源文件和旧备份") - for field in ("size", "modify_time"): - frozen_value, live_value = getattr(expected, field), getattr(current, field) - if frozen_value is None or live_value is None: - raise StorageQueryError(f"文件 {expected.path} 缺少 {field} 身份凭据,需人工复核") - if frozen_value != live_value: - raise TransferExecutionConflictError(f"文件 {expected.path} 的 {field} 已变化,禁止清理") + if expected.size is None or current.size is None: + raise StorageQueryError(f"文件 {expected.path} 缺少 size 身份凭据,需人工复核") + if expected.size != current.size: + raise TransferExecutionConflictError(f"文件 {expected.path} 的 size 已变化,禁止清理") if expected.type != current.type: raise TransferExecutionConflictError(f"文件 {expected.path} 的类型已变化,禁止清理") if expected.fileid: diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index fa95eca49..b1aaa96c5 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -788,8 +788,9 @@ SMB 同存储整理由 `app.modules.filemanager.storages.smb` 持有协议调用 步骤路径从冻结计划推导,DB 适配器逐项校验并要求前驱已持久成功;查询或操作结果未知时保留文件并隔离, 禁止删除旧目标后盲目重放。`latest` 旧版本清理和宿主历史目的清理在新目标成功后执行, 同存储重合目的不能整块删除新文件,本地符号链接路径在可超时的文件系统代理内规范化,并在清理前再次检查。 -暂存文件大小必须与源证据一致,清理源或备份前复核大小、修改时间及可用的稳定文件 ID, +暂存文件大小必须与源证据一致,清理源或备份前复核大小、类型及可用的稳定文件 ID, 新目标还必须匹配提升步骤的持久凭据;旧快照缺少必要证据或文件已变更时保留文件并转人工复核。 +修改时间可能由云盘延迟更新或缺失,不作为整理执行阶段的文件身份校验依据。 本地文件 ID 使用设备号和 inode,验证不额外读取整文件计算哈希。 外部 legacy transfer provider 继续遵循原有兼容合同。 diff --git a/tests/test_transfer_execution_runner.py b/tests/test_transfer_execution_runner.py index 9dd05c418..62001446e 100644 --- a/tests/test_transfer_execution_runner.py +++ b/tests/test_transfer_execution_runner.py @@ -397,9 +397,10 @@ class _ImmediateStepRunner: return execute() -def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path): +@pytest.mark.parametrize("frozen_time,live_time", [(1.0, 1.0), (1.0, 5.0), (None, 5.0), (1.0, None)]) +def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path, frozen_time, live_time): """ - 跨存储 move 必须先复制目标,再用独立步骤删除源。 + 跨存储 move 先复制再独立删除源,源和目标时间戳变化或缺失均不阻止清理。 """ source_path = tmp_path / "source.mkv" source_path.write_bytes(b"movie") @@ -409,7 +410,7 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa name=source_path.name, type="file", size=source_path.stat().st_size, - modify_time=source_path.stat().st_mtime, + modify_time=frozen_time, extension="mkv", ) target_item = FileItem( @@ -423,9 +424,10 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa ) source_oper = Mock() source_oper.delete.return_value = True - source_oper.get_item_strict.return_value = source_item + live_source = source_item.model_copy(update={"modify_time": live_time}) + source_oper.get_item_strict.return_value = live_source target_oper = Mock() - target_oper.get_item_strict.return_value = target_item + target_oper.get_item_strict.return_value = target_item.model_copy(update={"modify_time": live_time}) target_oper.get_folder.return_value = FileItem( storage="remote", path="/library", name="library", type="dir" ) @@ -450,7 +452,7 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa ] assert runner.steps[0][2]["transfer_type"] == "copy" target_oper.upload.assert_called_once() - source_oper.delete.assert_called_once_with(source_item) + source_oper.delete.assert_called_once_with(live_source) def test_remote_to_local_transfer_creates_target_directory_before_download(tmp_path): diff --git a/tests/test_transfer_safe_overwrite.py b/tests/test_transfer_safe_overwrite.py index e427e10a1..364b142d4 100644 --- a/tests/test_transfer_safe_overwrite.py +++ b/tests/test_transfer_safe_overwrite.py @@ -640,33 +640,75 @@ def test_completed_promotion_rechecks_live_files_before_destructive_cleanup( assert backup.read_bytes() == expected_backup +def test_completed_promotion_allows_cleanup_after_timestamp_changes( + tmp_path, monkeypatch, repository_factory, +): + """ + 提升结果已落账后,即使源、目标和备份的时间戳均变化,恢复仍按大小和身份完成清理。 + """ + source, target = tmp_path / "download.mkv", tmp_path / "Movie.2026.mkv" + source.write_bytes(b"new-version") + target.write_bytes(b"old-version") + checkpoint = _checkpoint(source, target, transfer_type="move") + repository = repository_factory(checkpoint) + runner = _durable_runner(repository, checkpoint) + real_run = runner.run + + class ProcessStopped(BaseException): + """ + 模拟提升结果落账后、清理源文件前进程退出。 + """ + + def stop_before_cleanup(**kwargs): + """ + 保留真实成功证据,只中断源文件清理步骤。 + """ + if kwargs["kind"] == "delete_overwrite_source": + raise ProcessStopped() + return real_run(**kwargs) + + monkeypatch.setattr(runner, "run", stop_before_cleanup) + with pytest.raises(ProcessStopped): + _execute(checkpoint, monkeypatch, runner) + backup = next(tmp_path.glob(".mp-backup-*")) + for path in (source, target, backup): + before = path.stat() + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns + 4_000_000_000)) + + result = _execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint)) + assert result.success + assert target.read_bytes() == b"new-version" + assert not source.exists() + assert not backup.exists() + + @pytest.mark.parametrize("missing_evidence", ["source", "target"]) def test_overwrite_preserves_all_copies_when_cleanup_identity_evidence_is_missing( tmp_path, monkeypatch, repository_factory, missing_evidence, ): """ - 旧快照或适配器缺少修改时间时,禁止把当前查询结果当作原文件凭据。 + 旧快照或适配器缺少大小时,禁止把当前查询结果当作原文件凭据。 """ source, target = tmp_path / "download.mkv", tmp_path / "Movie.2026.mkv" source.write_bytes(b"new-version") target.write_bytes(b"old-version") checkpoint = _checkpoint(source, target, transfer_type="move") if missing_evidence == "source": - frozen_source = {**checkpoint.items[0].source_fileitem, "modify_time": None} + frozen_source = {**checkpoint.items[0].source_fileitem, "size": None} checkpoint = replace(checkpoint, planning_input=replace(checkpoint.planning_input, source_fileitem=frozen_source), items=(replace(checkpoint.items[0], source_fileitem=frozen_source),)) else: real_query = LocalStorage.get_item_strict - def query_without_time(self, path): + def query_without_size(self, path): """ - 模拟目标适配器未提供可持久验证的修改时间。 + 模拟目标适配器未提供可持久验证的大小。 """ item = real_query(self, path) - return item.model_copy(update={"modify_time": None}) if item and path == target else item + return item.model_copy(update={"size": None}) if item and path == target else item - monkeypatch.setattr(LocalStorage, "get_item_strict", query_without_time) + monkeypatch.setattr(LocalStorage, "get_item_strict", query_without_size) repository = repository_factory(checkpoint) with pytest.raises(execution_module._TransferManualReviewRequired): _execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint))