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