354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""Portable dependency discovery around the byte-preserved Skill runner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
PACKAGE = Path(__file__).resolve().parent
|
|
# -I excludes even the script directory. Only add this installed package, never cwd.
|
|
sys.path.insert(0, str(PACKAGE))
|
|
from dependency_checks import check_function, check_startup, python_runtime
|
|
|
|
|
|
def upstream_runner():
|
|
name = "artemis_device_test_upstream_runner"
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, PACKAGE / "skill" / "scripts" / "tool_runner.py"
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
UPSTREAM = upstream_runner()
|
|
|
|
|
|
class DependencyError(ValueError):
|
|
"""Only stable, public error codes belong here."""
|
|
|
|
|
|
def dll_machine(path: Path) -> int:
|
|
"""Read just the DOS/PE headers; discovering a DLL never loads it."""
|
|
try:
|
|
with path.open("rb") as stream:
|
|
header = stream.read(64)
|
|
if len(header) != 64 or header[:2] != b"MZ":
|
|
raise ValueError
|
|
offset = struct.unpack_from("<I", header, 60)[0]
|
|
if not 64 <= offset <= 1024 * 1024:
|
|
raise ValueError
|
|
stream.seek(offset)
|
|
pe = stream.read(24)
|
|
if len(pe) != 24 or pe[:4] != b"PE\x00\x00":
|
|
raise ValueError
|
|
return struct.unpack_from("<H", pe, 4)[0]
|
|
except (OSError, ValueError, struct.error):
|
|
raise DependencyError("mediainfo_invalid_or_unreadable_pe") from None
|
|
|
|
|
|
def validate_library(path: Path) -> None:
|
|
machine = dll_machine(path)
|
|
expected = 0x8664 if struct.calcsize("P") == 8 else 0x14C
|
|
if machine != expected:
|
|
raise DependencyError("mediainfo_architecture_mismatch")
|
|
|
|
|
|
def path_directories():
|
|
seen = set()
|
|
for value in os.environ.get("PATH", "").split(os.pathsep):
|
|
value = value.strip().strip('"')
|
|
if not value or value.startswith(("\\\\", "//")):
|
|
continue
|
|
path = Path(value)
|
|
# Ignore empty/relative PATH entries: they must not search the working directory.
|
|
if not path.is_absolute() or not path.is_dir():
|
|
continue
|
|
resolved = path.resolve()
|
|
if resolved not in seen:
|
|
seen.add(resolved)
|
|
yield resolved
|
|
|
|
|
|
def media_library(profile, roots):
|
|
configured = profile.get("libraries", {}).get("mediainfo")
|
|
if configured:
|
|
path = Path(UPSTREAM.configured_local_path(configured, "mediainfo", r"MediaInfo\.dll"))
|
|
validate_library(path)
|
|
return str(path), "profile", []
|
|
rejected = []
|
|
candidates = [
|
|
(path, "tool_root")
|
|
for path in UPSTREAM.root_candidates(roots)
|
|
if path.name.lower() == "mediainfo.dll" and path.is_file()
|
|
]
|
|
candidates.extend((directory / "MediaInfo.dll", "path") for directory in path_directories())
|
|
seen = set()
|
|
for path, source in candidates:
|
|
if not path.is_file() or path.resolve() in seen:
|
|
continue
|
|
seen.add(path.resolve())
|
|
try:
|
|
validate_library(path)
|
|
except DependencyError as exc:
|
|
rejected.append({"path": str(path), "error_code": str(exc)})
|
|
continue
|
|
return str(path.resolve()), source, rejected
|
|
if rejected:
|
|
raise DependencyError("mediainfo_no_matching_architecture_or_valid_pe")
|
|
raise UPSTREAM.ToolMissing("mediainfo")
|
|
|
|
|
|
def resolve_dependency(tool_id, profile, roots):
|
|
"""Resolve one dependency only; an unrelated broken dependency cannot block it."""
|
|
effective = copy.deepcopy(profile)
|
|
rejected = []
|
|
if tool_id == "mediainfo":
|
|
path, source, rejected = media_library(profile, roots)
|
|
effective.setdefault("libraries", {})["mediainfo"] = path
|
|
elif (
|
|
tool_id == "binwalk"
|
|
and not profile.get("runtimes", {}).get("binwalk_python")
|
|
and not profile.get("tools", {}).get("binwalk")
|
|
):
|
|
# One documented opt-in directory convention, never an arbitrary Python on PATH.
|
|
for root in roots:
|
|
runtime = Path(root) / "binwalk-python" / "Scripts" / "python.exe"
|
|
if runtime.is_file():
|
|
effective.setdefault("runtimes", {})["binwalk_python"] = str(runtime.resolve())
|
|
break
|
|
prefix = UPSTREAM.command_prefix(tool_id, effective, roots)
|
|
if tool_id != "mediainfo":
|
|
path = prefix[0]
|
|
if profile.get("runtimes", {}).get("binwalk_python") and tool_id == "binwalk":
|
|
source = "profile.runtime"
|
|
elif profile.get("tools", {}).get(tool_id):
|
|
source = "profile"
|
|
elif tool_id == "binwalk" and effective.get("runtimes", {}).get("binwalk_python"):
|
|
source = "tool_root.binwalk-python"
|
|
else:
|
|
source = (
|
|
"tool_root"
|
|
if any(Path(path).is_relative_to(Path(root).resolve()) for root in roots)
|
|
else "path"
|
|
)
|
|
return (
|
|
effective,
|
|
prefix,
|
|
{"status": "found", "source": source, "path": path, "rejected": rejected},
|
|
)
|
|
|
|
|
|
CAPABILITIES = {
|
|
"probe_media": (
|
|
"ffprobe",
|
|
["RTSP connectivity and business expectations require task evidence"],
|
|
),
|
|
"media_info": ("mediainfo", []),
|
|
"analyze_pcap": ("tshark", []),
|
|
"capture_packets": (
|
|
"dumpcap",
|
|
["selected interface, Npcap and capture permission; no capture attempted"],
|
|
),
|
|
"scan_ports": ("nmap", ["target connectivity and requested port states; no scan attempted"]),
|
|
"scan_firmware": (
|
|
"binwalk",
|
|
["signature detection is not firmware compatibility or encryption acceptance"],
|
|
),
|
|
"capture_serial": (
|
|
"powershell",
|
|
["adapter driver, selected COM, baud, permission and exclusive access; no port opened"],
|
|
),
|
|
}
|
|
|
|
|
|
def capability_status(tools):
|
|
by_id = {row["tool_id"]: row for row in tools}
|
|
result = {}
|
|
for capability, (tool_id, conditions) in CAPABILITIES.items():
|
|
row = by_id[tool_id]
|
|
discovery, startup, function = (
|
|
row[key]["status"] for key in ("discovery", "startup", "functional")
|
|
)
|
|
blocked = (
|
|
discovery != "found"
|
|
or "failed" in {startup, function}
|
|
or "timed_out" in {startup, function}
|
|
)
|
|
result[capability] = {
|
|
"dependency": tool_id,
|
|
"status": "blocked"
|
|
if blocked
|
|
else "local_verified"
|
|
if function == "passed"
|
|
else "available_unverified",
|
|
"external_conditions": conditions,
|
|
}
|
|
return result
|
|
|
|
|
|
def doctor(profile, roots, *, check_versions=False, check_functional=False, timeout=42):
|
|
started = time.monotonic()
|
|
deadline = started + max(0.1, timeout - 1)
|
|
UPSTREAM.validate_profile(profile)
|
|
rows, prefixes = [], {}
|
|
catalog = json.loads(UPSTREAM.CATALOG.read_text("utf-8"))
|
|
for entry in catalog["tools"]:
|
|
row = dict(entry)
|
|
tool_id = entry["tool_id"]
|
|
if tool_id == "mediainfo":
|
|
row["capability"] = (
|
|
"通过 profile/统一目录/PATH 发现匹配位数的本地 MediaInfo.dll;独立子进程固定 API 和白名单字段,不使用 GUI/CLI 替代。"
|
|
)
|
|
if tool_id not in UPSTREAM.EXECUTABLES and tool_id != "mediainfo":
|
|
row["availability"] = {
|
|
"mcp": "mcp-check-required",
|
|
"orchestrator": "orchestrator-context",
|
|
}.get(entry.get("interface"), "manual-interface")
|
|
rows.append(row)
|
|
continue
|
|
row.update(startup={"status": "not_checked"}, functional={"status": "not_checked"})
|
|
try:
|
|
_, prefix, discovery = resolve_dependency(tool_id, profile, roots)
|
|
prefixes[tool_id] = prefix
|
|
row["discovery"] = discovery
|
|
row["availability"] = (
|
|
"library-found"
|
|
if tool_id == "mediainfo"
|
|
else "python-module-configured"
|
|
if tool_id == "binwalk" and len(prefix) > 1
|
|
else "executable-found"
|
|
)
|
|
except UPSTREAM.ToolMissing:
|
|
row.update(
|
|
availability="missing",
|
|
discovery={"status": "missing", "error_code": "dependency_not_found"},
|
|
)
|
|
except (UPSTREAM.ValidationError, DependencyError) as exc:
|
|
row.update(
|
|
availability="invalid", discovery={"status": "invalid", "error_code": str(exc)}
|
|
)
|
|
except OSError:
|
|
row.update(
|
|
availability="unavailable",
|
|
discovery={"status": "unavailable", "error_code": "dependency_path_unreadable"},
|
|
)
|
|
rows.append(row)
|
|
runtime = python_runtime(deadline, check_versions or check_functional)
|
|
with tempfile.TemporaryDirectory(prefix="artemis-dependency-check-") as temporary:
|
|
directory = Path(temporary)
|
|
# Complete startup checks first, so a slow functional check cannot hide discovery/load results.
|
|
if check_versions or check_functional:
|
|
for row in rows:
|
|
tool_id = row["tool_id"]
|
|
if tool_id in prefixes:
|
|
row["startup"] = check_startup(tool_id, prefixes[tool_id], directory, deadline)
|
|
if check_functional:
|
|
for row in rows:
|
|
tool_id = row["tool_id"]
|
|
if tool_id in prefixes:
|
|
row["functional"] = (
|
|
check_function(tool_id, prefixes[tool_id], directory, deadline)
|
|
if row["startup"]["status"] == "passed"
|
|
else {"status": "not_checked", "reason": "startup_not_verified"}
|
|
)
|
|
return {
|
|
"execution_status": "completed",
|
|
"test_verdict": "not_evaluated",
|
|
"tools": rows,
|
|
"capabilities": capability_status(rows),
|
|
"python_runtime": runtime,
|
|
"checks": {
|
|
"startup_requested": check_versions or check_functional,
|
|
"functional_requested": check_functional,
|
|
},
|
|
"external_conditions": {
|
|
"capture_driver": "interface enumeration does not prove packet capture permission",
|
|
"serial_driver": "port enumeration does not prove adapter driver or communication",
|
|
"gui_and_websites": "require actual application/window, authorized session and permissions",
|
|
"android": "requires Artemis device checks in the current task",
|
|
},
|
|
"device_actions_executed": False,
|
|
"catalog_evidence_is_historical": True,
|
|
"elapsed_seconds": round(time.monotonic() - started, 3),
|
|
"note": "Independent local checks; no scan, capture, serial open, download, installation or business verdict. PATH is inherited from the Artemis host process.",
|
|
}
|
|
|
|
|
|
def main(argv=None):
|
|
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
functional = "--check-functional" in arguments
|
|
if functional:
|
|
arguments.remove("--check-functional")
|
|
try:
|
|
args = UPSTREAM.parse_args(arguments)
|
|
if functional and args.command != "doctor":
|
|
raise UPSTREAM.ValidationError("functional_check_only_supported_for_doctor")
|
|
profile = UPSTREAM.load_profile(args.profile)
|
|
if args.command in {"list", "doctor"}:
|
|
result = doctor(
|
|
profile,
|
|
args.tool_root,
|
|
check_versions=bool(getattr(args, "check_versions", False) and not args.dry_run),
|
|
check_functional=functional and not args.dry_run,
|
|
timeout=UPSTREAM.bounded_int(
|
|
args.timeout or "42", 1, 3600, "timeout_must_be_1_to_3600_seconds"
|
|
),
|
|
)
|
|
else:
|
|
tool_id = {
|
|
"probe": "ffprobe",
|
|
"media-info": "mediainfo",
|
|
"analyze-pcap": "tshark",
|
|
"capture": "dumpcap",
|
|
"scan": "nmap",
|
|
"firmware-scan": "binwalk",
|
|
"serial": "powershell",
|
|
}[args.command]
|
|
effective, _, discovery = resolve_dependency(tool_id, profile, args.tool_root)
|
|
plan = UPSTREAM.build_plan(args, effective, os.environ)
|
|
result = (
|
|
{"execution_status": "planned", "test_verdict": "not_evaluated", **plan.public()}
|
|
if args.dry_run
|
|
else UPSTREAM.execute(plan)
|
|
)
|
|
result["dependency_discovery"] = discovery
|
|
code = 0 if result["execution_status"] in {"planned", "completed"} else 1
|
|
except UPSTREAM.ToolMissing as exc:
|
|
result = {
|
|
"execution_status": "tool_missing",
|
|
"test_verdict": "not_evaluated",
|
|
"tool_id": str(exc),
|
|
"error_code": "prepare_dependency_in_path_tool_root_or_explicit_profile",
|
|
}
|
|
code = 3
|
|
except (UPSTREAM.ValidationError, DependencyError) as exc:
|
|
result = {
|
|
"execution_status": "invalid_arguments",
|
|
"test_verdict": "not_evaluated",
|
|
"error_code": str(exc),
|
|
}
|
|
code = 2
|
|
except (OSError, ValueError):
|
|
result = {
|
|
"execution_status": "configuration_error",
|
|
"test_verdict": "not_evaluated",
|
|
"error_code": "local_file_unreadable_or_invalid",
|
|
}
|
|
code = 2
|
|
print(json.dumps(result, ensure_ascii=True, indent=2))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|