Files
Artemis-Plugins/plugins/artemis-device-test/skill/scripts/evidence.py
T

187 lines
9.0 KiB
Python

#!/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', '<br>')
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())