fix(transfer): ignore timestamps in cleanup identity checks

This commit is contained in:
jxxghp
2026-10-19 19:09:12 +08:00
parent cfec9497f5
commit 9f03157977
4 changed files with 65 additions and 22 deletions

View File

@@ -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:

View File

@@ -788,8 +788,9 @@ SMB 同存储整理由 `app.modules.filemanager.storages.smb` 持有协议调用
步骤路径从冻结计划推导,DB 适配器逐项校验并要求前驱已持久成功;查询或操作结果未知时保留文件并隔离,
禁止删除旧目标后盲目重放。`latest` 旧版本清理和宿主历史目的清理在新目标成功后执行,
同存储重合目的不能整块删除新文件,本地符号链接路径在可超时的文件系统代理内规范化,并在清理前再次检查。
暂存文件大小必须与源证据一致,清理源或备份前复核大小、修改时间及可用的稳定文件 ID,
暂存文件大小必须与源证据一致,清理源或备份前复核大小、类型及可用的稳定文件 ID,
新目标还必须匹配提升步骤的持久凭据;旧快照缺少必要证据或文件已变更时保留文件并转人工复核。
修改时间可能由云盘延迟更新或缺失,不作为整理执行阶段的文件身份校验依据。
本地文件 ID 使用设备号和 inode,验证不额外读取整文件计算哈希。
外部 legacy transfer provider 继续遵循原有兼容合同。

View File

@@ -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):

View File

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