213 lines
7.5 KiB
Python
213 lines
7.5 KiB
Python
"""Run opt-in, offline SDK fault regressions in temporary directories only."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
from contextlib import suppress
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
import psutil
|
|
|
|
from artemis.plugins.evidence import decode_receipt, persist_attachments
|
|
from artemis.plugins.models import PluginCallResult
|
|
from artemis.plugins.sdk import (
|
|
FileVerificationError,
|
|
ManagedProcessError,
|
|
ProcessTimedOut,
|
|
run_process,
|
|
snapshot_file,
|
|
)
|
|
|
|
PROBE = Path(__file__).with_name("_sdk_probe.py").resolve()
|
|
|
|
|
|
def _require(condition, message):
|
|
if not condition:
|
|
raise AssertionError(message)
|
|
|
|
|
|
def _owned_process(marker: Path, started: float):
|
|
if not marker.exists():
|
|
return None
|
|
pid = json.loads(marker.read_text("utf-8"))["pid"]
|
|
try:
|
|
process = psutil.Process(pid)
|
|
if process.status() == psutil.STATUS_ZOMBIE:
|
|
return None
|
|
_require(process.create_time() >= started - 1, "测试 PID 的启动时间不匹配")
|
|
_require(str(PROBE) in process.cmdline(), "测试 PID 的脚本身份不匹配")
|
|
return process
|
|
except psutil.NoSuchProcess:
|
|
return None
|
|
|
|
|
|
async def _tree_case(root: Path, mode: str):
|
|
marker = root / f"{mode}.json"
|
|
started = time.time()
|
|
task = asyncio.create_task(
|
|
run_process(
|
|
[
|
|
sys.executable,
|
|
"-I",
|
|
"-B",
|
|
str(PROBE),
|
|
"tree",
|
|
str(marker),
|
|
"exit" if mode == "normal" else "linger",
|
|
],
|
|
cwd=root,
|
|
timeout=2 if mode == "timeout" else 10,
|
|
)
|
|
)
|
|
try:
|
|
if mode == "cancel":
|
|
async with asyncio.timeout(5):
|
|
while not marker.exists() and not task.done():
|
|
await asyncio.sleep(0.02)
|
|
_require(marker.exists(), "取消探针没有启动")
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
else:
|
|
raise AssertionError("取消没有传播")
|
|
elif mode == "timeout":
|
|
try:
|
|
await task
|
|
except ProcessTimedOut:
|
|
pass
|
|
else:
|
|
raise AssertionError("超时没有生效")
|
|
else:
|
|
result = await task
|
|
_require(result.returncode == 0, "模拟工具异常退出")
|
|
_require(marker.exists(), "后代进程探针没有启动")
|
|
async with asyncio.timeout(3):
|
|
while _owned_process(marker, started) is not None:
|
|
await asyncio.sleep(0.02)
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError, ManagedProcessError):
|
|
await task
|
|
# Failing implementations must not leave the fault suite's own sleeper alive.
|
|
process = _owned_process(marker, started)
|
|
if process is not None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
async def run_checks() -> dict:
|
|
checks = []
|
|
with tempfile.TemporaryDirectory(prefix="artemis-sdk-faults-") as temporary:
|
|
root = Path(temporary)
|
|
source = root / "synthetic.rom"
|
|
approved = b"approved firmware bytes\x00\xff"
|
|
source.write_bytes(approved)
|
|
digest = hashlib.sha256(approved).hexdigest()
|
|
snapshot = snapshot_file(source, digest)
|
|
for replacement in (b"X" * len(approved), b"", b"Y" * (len(approved) + 10)):
|
|
source.write_bytes(replacement)
|
|
_require(b"".join(snapshot.chunks(3)) == approved, "快照受原文件修改影响")
|
|
try:
|
|
snapshot_file(source, digest)
|
|
except FileVerificationError:
|
|
pass
|
|
else:
|
|
raise AssertionError("摘要不符仍被接收")
|
|
_require(all(part.readonly for part in snapshot.chunks()), "分块内容可被修改")
|
|
checks.append("file_replacement_truncation_growth_and_digest")
|
|
result = await run_process(
|
|
[sys.executable, "-I", "-B", str(PROBE), "echo"],
|
|
cwd=root,
|
|
timeout=5,
|
|
input_data=snapshot.data,
|
|
)
|
|
_require(
|
|
result.stdout == approved and result.stderr == b"synthetic-stderr",
|
|
"二进制输入输出不一致",
|
|
)
|
|
checks.append("binary_input_and_separate_output")
|
|
failed = await run_process(
|
|
[sys.executable, "-I", "-B", str(PROBE), "failure"], cwd=root, timeout=5
|
|
)
|
|
_require(failed.returncode == 7, "非零退出码被隐藏")
|
|
checks.append("nonzero_returncode")
|
|
for mode in ("normal", "timeout", "cancel"):
|
|
await _tree_case(root, mode)
|
|
checks.append(f"descendants_reclaimed_on_{mode}")
|
|
try:
|
|
await run_process(
|
|
[sys.executable, "-I", "-B", str(PROBE), "overflow"],
|
|
cwd=root,
|
|
timeout=5,
|
|
max_output_bytes=1024,
|
|
)
|
|
except ManagedProcessError as exc:
|
|
_require(exc.error_code == "process_output_limit", "输出超限错误类型不正确")
|
|
else:
|
|
raise AssertionError("输出超限没有停止")
|
|
checks.append("output_limit_stops_process")
|
|
trace = root / "synthetic-trace"
|
|
artifacts = trace / "artifacts/plugins/sdk-faults"
|
|
artifacts.mkdir(parents=True)
|
|
sample = artifacts / "sample.txt"
|
|
sample.write_bytes(b"synthetic evidence")
|
|
receipt = {"name": sample.name, "sha256": hashlib.sha256(sample.read_bytes()).hexdigest()}
|
|
_require(len(persist_attachments(trace, "sdk-faults", [receipt])) == 1, "附件未被回读")
|
|
for invalid in ({**receipt, "sha256": "0" * 64}, {**receipt, "name": "../outside.txt"}):
|
|
try:
|
|
persist_attachments(trace, "sdk-faults", [invalid])
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("证据摘要不符或路径穿越未拒绝")
|
|
checks.append("evidence_hash_and_path_rejection")
|
|
for execution in ("partial", "unknown"):
|
|
outcome = PluginCallResult(
|
|
status="success",
|
|
structured_content={
|
|
"artemis": {
|
|
"version": 1,
|
|
"execution": execution,
|
|
"side_effect": "possible",
|
|
"retryable": True,
|
|
}
|
|
},
|
|
)
|
|
decode_receipt(outcome)
|
|
_require(
|
|
not outcome.retryable and outcome.requires_observation, "不确定写操作被允许重试"
|
|
)
|
|
checks.append("partial_unknown_never_authorize_retries")
|
|
return {
|
|
"status": "passed",
|
|
"scope": "sdk_faults_only",
|
|
"python": sys.version.split()[0],
|
|
"platform": sys.platform,
|
|
"checks": checks,
|
|
"real_device_actions": False,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--json-output", type=Path)
|
|
args = parser.parse_args()
|
|
result = asyncio.run(run_checks())
|
|
if args.json_output:
|
|
args.json_output.write_text(
|
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
|
|
)
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
print("仅验证 SDK 自身;插件仍需对真实入口做故障注入、证据与任务验收。")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|