muse-agent-example/tests/单元/test_文件归档恢复.py
zizi 9e6f1c4481 R2 改造交付:新版模块化单体全量成果
- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具)
- 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺
- 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口
- 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影)
- 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威)
- R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
2026-09-15 12:47:42 +08:00

225 lines
8.3 KiB
Python

from __future__ import annotations
import io
import json
import stat
import tarfile
from pathlib import Path
import pytest
from muse.基础设施 import 数据归档
from muse.基础设施.备份恢复 import 备份恢复错误, 解包备份
from muse.基础设施.数据归档 import 创建文件归档, 归档错误, 恢复文件归档, 核对文件归档
def _模式(路径: Path) -> int:
return stat.S_IMODE(路径.stat().st_mode)
def _建立基本来源(tmp_path: Path) -> tuple[Path, Path]:
raw = tmp_path / "raw"
drafts = tmp_path / "drafts"
(raw / "证据").mkdir(parents=True)
drafts.mkdir()
(raw / "证据" / "原字节.bin").write_bytes(b"\x00\xff\nraw\x80")
(drafts / "第一章 草稿.txt").write_text("风从旧城来。\n", encoding="utf-8")
return raw, drafts
def test_原字节_unicode_布局和私有权限往返__29a001(tmp_path: Path) -> None:
raw, drafts = _建立基本来源(tmp_path)
(raw / "证据" / "原字节.bin").chmod(0o644)
归档 = tmp_path / "backup"
清单 = 创建文件归档({"raw": raw, "drafts": drafts}, 归档)
恢复raw = tmp_path / "restored-raw"
恢复drafts = tmp_path / "restored-drafts"
恢复结果 = 恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert 恢复结果 == 清单 == 核对文件归档(归档)
assert (恢复raw / "证据" / "原字节.bin").read_bytes() == b"\x00\xff\nraw\x80"
assert (恢复drafts / "第一章 草稿.txt").read_text(encoding="utf-8") == "风从旧城来。\n"
assert 清单["file_count"] == 2
assert 清单["total_bytes"] == len(b"\x00\xff\nraw\x80") + len("风从旧城来。\n".encode())
assert _模式(归档) == _模式(恢复raw) == _模式(恢复drafts) == 0o700
assert _模式(归档 / "manifest.json") == 0o600
assert _模式(恢复raw / "证据" / "原字节.bin") == 0o600
清单正文 = (归档 / "manifest.json").read_text(encoding="utf-8")
assert str(raw) not in 清单正文
assert "风从旧城来" not in 清单正文
def test_空根和缺失根保持区别__29a002(tmp_path: Path) -> None:
raw = tmp_path / "raw"
(raw / "空子目录").mkdir(parents=True)
drafts = tmp_path / "missing-drafts"
归档 = tmp_path / "backup"
清单 = 创建文件归档({"raw": raw, "drafts": drafts}, 归档)
恢复raw = tmp_path / "restored-raw"
恢复drafts = tmp_path / "restored-drafts"
恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert 清单["roots"]["raw"] == {
"exists": True,
"directories": ["空子目录"],
"files": [],
}
assert 清单["roots"]["drafts"] == {"exists": False, "directories": [], "files": []}
assert (恢复raw / "空子目录").is_dir()
assert not 恢复drafts.exists()
def test_归档文件被篡改时拒绝且不恢复__29a003(tmp_path: Path) -> None:
raw, drafts = _建立基本来源(tmp_path)
归档 = tmp_path / "backup"
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
(归档 / "data" / "raw" / "证据" / "原字节.bin").write_bytes(b"tampered")
恢复raw = tmp_path / "restored-raw"
恢复drafts = tmp_path / "restored-drafts"
with pytest.raises(归档错误):
恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert not 恢复raw.exists()
assert not 恢复drafts.exists()
def test_清单路径逃逸在恢复前拒绝__29a004(tmp_path: Path) -> None:
raw, drafts = _建立基本来源(tmp_path)
归档 = tmp_path / "backup"
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
清单路径 = 归档 / "manifest.json"
清单 = json.loads(清单路径.read_text(encoding="utf-8"))
清单["roots"]["raw"]["files"][0]["path"] = "../escape.bin"
清单路径.write_text(json.dumps(清单), encoding="utf-8")
恢复raw = tmp_path / "restored-raw"
恢复drafts = tmp_path / "restored-drafts"
with pytest.raises(归档错误):
恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert not (tmp_path / "escape.bin").exists()
assert not 恢复raw.exists()
assert not 恢复drafts.exists()
def test_来源符号链接被拒绝并清理归档__29a005(tmp_path: Path) -> None:
raw = tmp_path / "raw"
drafts = tmp_path / "drafts"
raw.mkdir()
drafts.mkdir()
外部 = tmp_path / "outside.txt"
外部.write_text("不可跟随", encoding="utf-8")
(raw / "link.txt").symlink_to(外部)
归档 = tmp_path / "backup"
with pytest.raises(归档错误):
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
assert not 归档.exists()
assert 外部.read_text(encoding="utf-8") == "不可跟随"
def test_已有恢复目标不覆盖且其他根不创建__29a006(tmp_path: Path) -> None:
raw, drafts = _建立基本来源(tmp_path)
归档 = tmp_path / "backup"
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
恢复raw = tmp_path / "restored-raw"
恢复raw.mkdir()
标记 = 恢复raw / "keep.txt"
标记.write_text("保留", encoding="utf-8")
恢复drafts = tmp_path / "restored-drafts"
with pytest.raises(归档错误):
恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert 标记.read_text(encoding="utf-8") == "保留"
assert not 恢复drafts.exists()
def test_多根恢复中断清理本次半成品__29a007(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
raw, drafts = _建立基本来源(tmp_path)
归档 = tmp_path / "backup"
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
原复制 = 数据归档._复制归档文件到目标
次数 = 0
def 中断(根句柄: int, 根名: str, 条目: dict, 目标根: Path) -> None:
nonlocal 次数
次数 += 1
if 次数 == 2:
raise OSError("synthetic interruption")
原复制(根句柄, 根名, 条目, 目标根)
monkeypatch.setattr(数据归档, "_复制归档文件到目标", 中断)
恢复raw = tmp_path / "restored-raw"
恢复drafts = tmp_path / "restored-drafts"
with pytest.raises(归档错误):
恢复文件归档(归档, {"raw": 恢复raw, "drafts": 恢复drafts})
assert 次数 == 2
assert not 恢复raw.exists()
assert not 恢复drafts.exists()
def test_未知根和已有输出目录均被拒绝__29a008(tmp_path: Path) -> None:
raw = tmp_path / "raw"
raw.mkdir()
未创建归档 = tmp_path / "not-created"
with pytest.raises(归档错误):
创建文件归档({"assets": raw}, 未创建归档)
assert not 未创建归档.exists()
已有归档 = tmp_path / "existing"
已有归档.mkdir()
标记 = 已有归档 / "keep.txt"
标记.write_text("保留", encoding="utf-8")
with pytest.raises(归档错误):
创建文件归档({"raw": raw}, 已有归档)
assert 标记.read_text(encoding="utf-8") == "保留"
def test_清单外文件不能静默夹带__29a009(tmp_path: Path) -> None:
raw, drafts = _建立基本来源(tmp_path)
归档 = tmp_path / "backup"
创建文件归档({"raw": raw, "drafts": drafts}, 归档)
夹带 = 归档 / "data" / "raw" / "extra.bin"
夹带.write_bytes(b"extra")
夹带.chmod(0o600)
with pytest.raises(归档错误):
核对文件归档(归档)
@pytest.mark.parametrize("kind", ["escape", "symlink", "duplicate", "extra"])
def test_备份解包拒绝越界链接重复与额外路径且不碰既有文件__29a010(tmp_path, kind):
outside = tmp_path / "outside"
outside.write_bytes(b"keep")
packet = tmp_path / "bad.tar"
with tarfile.open(packet, "w") as archive:
if kind == "escape":
entry = tarfile.TarInfo("../outside")
elif kind == "extra":
entry = tarfile.TarInfo("unlisted.txt")
else:
entry = tarfile.TarInfo("database.dump")
if kind == "symlink":
entry.type = tarfile.SYMTYPE
entry.linkname = str(outside)
archive.addfile(entry)
else:
entry.size = 3
archive.addfile(entry, io.BytesIO(b"bad"))
if kind == "duplicate":
archive.addfile(entry, io.BytesIO(b"new"))
target = tmp_path / "new-output"
with pytest.raises(备份恢复错误):
解包备份(packet, target, "bkp-" + "0" * 40)
assert not target.exists() and outside.read_bytes() == b"keep"