152 lines
7.4 KiB
Python
152 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Local source snapshot, UTF-8 ZIP and SHA-256 verification (Python stdlib)."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import zipfile
|
|
|
|
SOURCE = Path(__file__).resolve().parents[1]
|
|
ROOTS = (
|
|
'AGENTS.md', 'PROJECT_PROGRESS.md', '新对话接手说明.md', '工具使用说明.md',
|
|
'1-生成工作台.command', '1-生成工作台.bat',
|
|
'2-求解与设计分析.command', '2-求解与设计分析.bat',
|
|
'3-关卡查重器.command', '3-关卡查重器.bat',
|
|
'colorBlock', 'colorBlock-level-generator', 'colorBlock-similarity-checker',
|
|
'levels', '手动关卡', 'outputs', '1-50new', '1-50old', 'level8.json', 'scripts',
|
|
)
|
|
EXCLUDED_NAMES = {'.git', '.DS_Store', '__MACOSX', 'node_modules', '__pycache__',
|
|
'.pytest_cache', '.vscode', '.codebuddy', '.agents'}
|
|
EXCLUDED_PATHS = {'colorBlock/library', 'colorBlock/temp', 'colorBlock/build',
|
|
'colorBlock/local', 'colorBlock/project.private.config.json',
|
|
'colorBlock-level-generator/settings/local.json'}
|
|
|
|
|
|
def digest(path):
|
|
h = hashlib.sha256()
|
|
with path.open('rb') as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def excluded(path, relative):
|
|
if path.name in EXCLUDED_NAMES or relative in EXCLUDED_PATHS:
|
|
return '缓存、依赖、本机配置或编辑器元数据'
|
|
if path.is_symlink():
|
|
return '符号链接不跟随,避免引入包外内容'
|
|
if path.name == '.env' or path.name.startswith('.env.') or path.suffix.lower() in {'.pem', '.key', '.p12'}:
|
|
return '潜在私密配置/密钥不随包转移'
|
|
return None
|
|
|
|
|
|
def records(folder):
|
|
result = []
|
|
for path in sorted(folder.rglob('*')):
|
|
if path.is_file() and path.name != 'TRANSFER_MANIFEST.json':
|
|
result.append({'path': path.relative_to(folder).as_posix(),
|
|
'bytes': path.stat().st_size, 'sha256': digest(path),
|
|
'mode': stat.S_IMODE(path.stat().st_mode)})
|
|
return result
|
|
|
|
|
|
def verify(target):
|
|
if target.is_dir():
|
|
manifest = json.loads((target / 'TRANSFER_MANIFEST.json').read_text(encoding='utf-8'))
|
|
actual = records(target)
|
|
assert actual == manifest['files'], '目录文件/校验值/权限与清单不一致'
|
|
else:
|
|
with zipfile.ZipFile(target) as archive:
|
|
assert archive.testzip() is None, 'ZIP CRC错误'
|
|
names = archive.namelist()
|
|
roots = {name.split('/')[0] for name in names}
|
|
assert len(roots) == 1, 'ZIP必须有唯一顶层项目目录'
|
|
prefix = roots.pop() + '/'
|
|
manifest = json.loads(archive.read(prefix + 'TRANSFER_MANIFEST.json'))
|
|
expected = {prefix + entry['path'] for entry in manifest['files']}
|
|
assert set(names) == expected | {prefix + 'TRANSFER_MANIFEST.json'}, 'ZIP存在缺失/额外文件'
|
|
for entry in manifest['files']:
|
|
name = prefix + entry['path']
|
|
assert '..' not in Path(entry['path']).parts and not Path(entry['path']).is_absolute()
|
|
assert hashlib.sha256(archive.read(name)).hexdigest() == entry['sha256'], name
|
|
info = archive.getinfo(name)
|
|
assert info.file_size == entry['bytes'], name
|
|
assert stat.S_IMODE(info.external_attr >> 16) == entry['mode'], name
|
|
print(json.dumps({'verified': str(target), 'files': len(manifest['files']),
|
|
'bytes': sum(item['bytes'] for item in manifest['files'])}, ensure_ascii=False))
|
|
|
|
|
|
def build(destination):
|
|
destination = destination.resolve()
|
|
archive_path = destination.with_name(destination.name + '.zip')
|
|
if destination.exists() or archive_path.exists():
|
|
raise SystemExit('目标已存在,拒绝覆盖;请选择新名称。')
|
|
for root in ROOTS:
|
|
if not (SOURCE / root).exists():
|
|
raise SystemExit('缺少必需交接内容:' + root)
|
|
if destination == SOURCE / root or SOURCE / root in destination.parents:
|
|
raise SystemExit('目标不可位于被复制的源子目录内。')
|
|
destination.mkdir(parents=True)
|
|
skipped = []
|
|
originals = []
|
|
|
|
def copy(path):
|
|
relative = path.relative_to(SOURCE).as_posix()
|
|
reason = excluded(path, relative)
|
|
if reason:
|
|
skipped.append({'path': relative, 'reason': reason})
|
|
return
|
|
target = destination / relative
|
|
if path.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
for child in sorted(path.iterdir()):
|
|
copy(child)
|
|
elif path.is_file():
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
source_hash = digest(path)
|
|
shutil.copy2(path, target)
|
|
assert source_hash == digest(target) == digest(path), '复制中源文件变化:' + relative
|
|
originals.append({'path': relative, 'sha256': source_hash})
|
|
|
|
for root in ROOTS:
|
|
copy(SOURCE / root)
|
|
markdown = sorted(path.relative_to(destination).as_posix()
|
|
for path in destination.rglob('*.md'))
|
|
index = ['# 转移包 Markdown 索引', '', '先读根目录“新对话接手说明.md”,再按导航阅读。历史报告可能包含已撤回结论,以PROJECT_PROGRESS最新状态为准。', '',
|
|
'源文件MD数量:' + str(len(markdown)), '']
|
|
index.extend('- [' + path + '](<' + path + '>)' for path in markdown)
|
|
(destination / 'DOCUMENT_INDEX.md').write_text('\n'.join(index) + '\n', encoding='utf-8')
|
|
manifest = {'schema': 1, 'handoffVersion': '1.47.1', 'date': '2026-10-12',
|
|
'runtimeVersions': {'generatorAnalysis': '0.19.0', 'similarity': '1.1.1'},
|
|
'scope': list(ROOTS), 'excluded': skipped,
|
|
'notes': ['ZIP/目录是本地快照,不会迁移对话内隐记忆。',
|
|
'排除项只影响打包,未删除/修改原工作区内容。',
|
|
'清单不递归校验自身;其余文件逐字节及权限均校验。',
|
|
'包外MatchMaster/Downloads数据/编辑器程序未收录。'],
|
|
'sourceFiles': originals, 'files': records(destination)}
|
|
(destination / 'TRANSFER_MANIFEST.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
verify(destination)
|
|
with zipfile.ZipFile(archive_path, 'x', zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
|
for path in sorted(destination.rglob('*')):
|
|
if path.is_file():
|
|
archive.write(path, destination.name + '/' + path.relative_to(destination).as_posix())
|
|
verify(archive_path)
|
|
print(json.dumps({'zip': str(archive_path), 'zipBytes': archive_path.stat().st_size,
|
|
'zipSha256': digest(archive_path), 'sourceMarkdownFiles': len(markdown),
|
|
'excludedEntries': len(skipped)}, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument('--build', type=Path, help='新快照目录(拒绝覆盖)')
|
|
group.add_argument('--verify', type=Path, help='校验已生成目录或ZIP')
|
|
arguments = parser.parse_args()
|
|
if arguments.build:
|
|
build(arguments.build)
|
|
else:
|
|
verify(arguments.verify)
|