#!/usr/bin/env python3 """Build an independent evidence bundle. Never infer business PASS from tool exit codes.""" from __future__ import annotations import argparse import hashlib import json import re import shutil import sys import zipfile from datetime import datetime, timezone from pathlib import Path, PureWindowsPath VERDICTS = {'passed', 'failed', 'blocked', 'inconclusive'} def digest(path: Path) -> str: h = hashlib.sha256() with path.open('rb') as stream: for block in iter(lambda: stream.read(1024 * 1024), b''): h.update(block) return h.hexdigest() def inside(root: Path, name: str) -> Path: if not isinstance(name, str) or not name.strip(): raise ValueError('Evidence path must be a non-empty relative path') if Path(name).is_absolute() or PureWindowsPath(name).is_absolute() or ':' in name: raise ValueError('Evidence path must be relative to evidence_root') candidate = (root / name).resolve() if not candidate.is_relative_to(root) or '..' in Path(name.replace('\\', '/')).parts: raise ValueError('Evidence path escapes evidence_root') return candidate def overall(assertions: list[dict]) -> str: required = [a['verdict'] for a in assertions if a.get('required', True)] if not required: return 'inconclusive' for status in ('failed', 'blocked', 'inconclusive'): if status in required: return status return 'passed' def cell(value) -> str: return str(value).replace('|', '\\|').replace('\n', '
') def bundle(manifest_path: Path, output: Path) -> dict: manifest_path = manifest_path.resolve(strict=True) output = output.resolve() if output.exists(): raise FileExistsError('Output already exists; use a new run directory') source = json.loads(manifest_path.read_text(encoding='utf-8-sig')) if not isinstance(source, dict) or source.get('schema_version') != 1: raise ValueError('schema_version must be 1') run_id = source.get('run_id') if not isinstance(run_id, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', run_id): raise ValueError('run_id must be 1..100 letters, digits, _, -, or .') root = (manifest_path.parent / source.get('evidence_root', '.')).resolve(strict=True) assertions = source.get('assertions') if not root.is_dir() or not isinstance(assertions, list): raise ValueError('evidence_root must be a directory and assertions must be a list') context = source.get('context', {}) if not isinstance(context, dict): raise ValueError('context must be an object') execution_mode = context.get('execution_mode', 'unspecified') if execution_mode not in {'live', 'offline', 'simulation', 'unspecified'}: raise ValueError('execution_mode must be live, offline, simulation or unspecified') real_device = context.get('real_device_test_executed') if real_device is not None and not isinstance(real_device, bool): raise ValueError('real_device_test_executed must be Boolean') if execution_mode in {'offline', 'simulation'} and real_device: raise ValueError('Offline/simulation cannot claim real device execution') ids = set() checked = [] artifacts = {} for original in assertions: if not isinstance(original, dict): raise ValueError('Each assertion must be an object') a = dict(original) identity = a.get('id') if not isinstance(identity, str) or not identity or identity in ids: raise ValueError('Assertion IDs must be non-empty and unique') ids.add(identity) if a.get('verdict') not in VERDICTS or not isinstance(a.get('required', True), bool): raise ValueError('Use explicit verdict and Boolean required') if 'expected' not in a or 'actual' not in a: raise ValueError('Each assertion needs expected and actual observations') refs = a.get('evidence', []) if not isinstance(refs, list): raise ValueError('evidence must be a list') a['requested_evidence'] = refs problems = [] normalized = [] for ref in refs: ref = {'path': ref} if isinstance(ref, str) else ref if not isinstance(ref, dict): raise ValueError('Evidence references must be paths or objects') file = inside(root, ref.get('path')) relative = file.relative_to(root).as_posix() if not file.is_file(): problems.append(f'Missing evidence: {relative}') continue sha = digest(file) expected_hash = ref.get('sha256') artifact = artifacts.setdefault(relative, {'path': 'evidence/' + relative, 'sha256': sha, 'size_bytes': file.stat().st_size, 'integrity': 'recorded'}) if expected_hash is not None and expected_hash != sha: problems.append(f'Hash mismatch: {relative}') artifact['integrity'] = 'mismatch' elif expected_hash is not None and artifact['integrity'] != 'mismatch': artifact['integrity'] = 'verified' normalized.append('evidence/' + relative) a['requested_verdict'] = a['verdict'] a['evidence'] = normalized if a['verdict'] in {'passed', 'failed'} and (problems or not normalized): a['verdict'] = 'inconclusive' if not normalized: problems.append('No valid evidence for the asserted observation') a['evidence_issues'] = problems checked.append(a) result = { 'schema_version': 1, 'run_id': run_id, 'created_at': datetime.now(timezone.utc).isoformat(), 'report_type': 'independent_combined_evidence', 'verdict': overall(checked), 'assertions': checked, 'artifacts': list(artifacts.values()), 'context': context, 'notice': 'Evidence integrity is checked; the author remains responsible for semantic assertions. ' 'Original artifacts may contain internal data. This is not an Artemis native verdict.', } # All input validation precedes output creation. A changed source aborts rather than publishing stale hashes. output.mkdir(parents=True, exist_ok=False) for relative, artifact in artifacts.items(): target = output / artifact['path'] target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(root / relative, target) if digest(target) != artifact['sha256']: raise ValueError('Evidence changed while copying; output is incomplete') (output / 'manifest.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') live_label = '是' if real_device is True else ('否' if real_device is False else '未标注') lines = [f'# 联合测试记录:{run_id}', '', f"结论:**{result['verdict']}**", '', f'运行性质:**{execution_mode}**;含真机操作:**{live_label}**。', '', '此报告关联 PC 与手机证据,不改写 Artemis 原生判定。工具执行结束不等于测试通过。', '', '| 断言 | 必需 | 预期 | 实际 | 判定 | 证据 |', '| --- | --- | --- | --- | --- | --- |'] for a in checked: refs_text = ', '.join(f'[{Path(p).name}]({p.replace(" ", "%20")})' for p in a['evidence']) lines.append('| ' + ' | '.join(cell(v) for v in ( a['id'], a.get('required', True), a['expected'], a['actual'], a['verdict'], refs_text)) + ' |') for problem in a['evidence_issues']: lines.append(f"\n证据问题({cell(a['id'])}):{cell(problem)}\n") lines += ['', '机器可读的运行关联信息、文件哈希及原始请求判定见 manifest.json。', '归档包含原始证据,按内部测试资料保管;本脚本不自动向外部发送或发布。', ''] (output / 'report.md').write_text('\n'.join(lines), encoding='utf-8') with zipfile.ZipFile(output / 'bundle.zip', 'x', zipfile.ZIP_DEFLATED) as archive: for file in sorted(output.rglob('*')): if file.is_file() and file != output / 'bundle.zip': archive.write(file, file.relative_to(output).as_posix()) return result def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='command', required=True) b = sub.add_parser('bundle') b.add_argument('--manifest', type=Path, required=True) b.add_argument('--out', type=Path, required=True) args = parser.parse_args(argv) try: result = bundle(args.manifest, args.out) print(json.dumps({'execution_status': 'completed', 'verdict': result['verdict'], 'output': str(args.out.resolve()), 'assertions': len(result['assertions'])}, ensure_ascii=False)) return 0 except (OSError, ValueError, TypeError) as exc: print(json.dumps({'execution_status': 'error', 'message': str(exc)}, ensure_ascii=False)) return 2 if __name__ == '__main__': if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8') raise SystemExit(main())