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

1150 lines
40 KiB
Python

#!/usr/bin/env python3
"""Small stdlib adapter for explicitly scoped device-test evidence collection.
No installers, arbitrary arguments, credential persistence, or test verdicts.
Run --help for commands. Global options must precede the subcommand.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import io
import ipaddress
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import parse_qsl, unquote, urlsplit
BASE = Path(__file__).resolve().parents[1]
CATALOG = BASE / "assets" / "tool-catalog.json"
SERIAL_SCRIPT = BASE / "scripts" / "vendor" / "serial_monitor.ps1"
SERIAL_SHA256 = "173584db05950b9842f2485eec5b33c0b16062f944216c283a18e038a9138a67"
EXECUTABLES = {
"ffprobe": ("ffprobe.exe", "ffprobe"),
"ffmpeg": ("ffmpeg.exe", "ffmpeg"),
"tshark": ("tshark.exe", "tshark"),
"dumpcap": ("dumpcap.exe", "dumpcap"),
"nmap": ("nmap.exe", "nmap"),
"binwalk": ("binwalk.exe", "binwalk"),
"powershell": ("powershell.exe", "pwsh.exe", "powershell", "pwsh"),
}
VERSION_FLAGS = {
"ffprobe": "-version",
"ffmpeg": "-version",
"binwalk": "--help",
"powershell": "-Version",
}
PCAP_FIELDS = (
"frame.number",
"frame.time_relative",
"frame.len",
"ip.src",
"ip.dst",
"ipv6.src",
"ipv6.dst",
"tcp.srcport",
"tcp.dstport",
"udp.srcport",
"udp.dstport",
"_ws.col.Protocol",
)
STREAM_FIELDS = (
"index",
"codec_name",
"codec_type",
"width",
"height",
"avg_frame_rate",
"r_frame_rate",
"sample_rate",
"channels",
"bit_rate",
)
FORMAT_FIELDS = ("format_name", "duration", "size", "bit_rate")
MEDIAINFO_FIELDS = {
"general": ("Format", "Duration", "FileSize", "OverallBitRate"),
"video": (
"Format",
"CodecID",
"Width",
"Height",
"FrameRate",
"FrameCount",
"BitRate",
"Duration",
),
"audio": ("Format", "CodecID", "SamplingRate", "Channels", "BitRate", "Duration"),
}
SECRET_KEYS = {
"password",
"passwd",
"pwd",
"secret",
"token",
"access_token",
"api_key",
"apikey",
"authorization",
"密码",
"口令",
}
class ValidationError(ValueError):
"""The message is an internal constant, never an untrusted argument."""
class ToolMissing(RuntimeError):
pass
class SafeParser(argparse.ArgumentParser):
def error(self, message):
# argparse normally repeats invalid arguments, which can contain secrets.
raise ValidationError("invalid_arguments_use_help")
def parse_args(argv=None):
p = SafeParser(description=__doc__, allow_abbrev=False)
p.add_argument(
"--profile",
help="JSON profile; tools maps tool_id to an existing executable path",
)
p.add_argument(
"--tool-root",
action="append",
default=[],
help="Trusted extracted-tool directory (repeatable)",
)
p.add_argument("--timeout", help="Process timeout, integer 1..3600 seconds")
p.add_argument(
"--dry-run",
action="store_true",
help="Validate and print a redacted plan without launching",
)
sub = p.add_subparsers(dest="command", required=True, parser_class=SafeParser)
sub.add_parser(
"list",
help="Read the tool catalog and discover local executables",
allow_abbrev=False,
)
doctor = sub.add_parser(
"doctor",
help="Check local availability; does not contact devices",
allow_abbrev=False,
)
doctor.add_argument(
"--check-versions",
action="store_true",
help="Explicitly run bounded version/help commands",
)
probe = sub.add_parser(
"probe",
help="Inspect local media or an RTSP URL from an environment variable",
allow_abbrev=False,
)
source = probe.add_mutually_exclusive_group(required=True)
source.add_argument("--input")
source.add_argument("--rtsp-env")
probe.add_argument("--transport", choices=("tcp", "udp"), default="tcp")
media = sub.add_parser(
"media-info",
help="Read local media metadata with a configured Windows MediaInfo.dll",
allow_abbrev=False,
)
media.add_argument("--input", required=True)
pcap = sub.add_parser(
"analyze-pcap",
help="Read a local capture using fixed non-payload fields",
allow_abbrev=False,
)
pcap.add_argument("--input", required=True)
pcap.add_argument("--max-packets", default="1000")
cap = sub.add_parser(
"capture",
help="Capture one host on an explicitly selected interface",
allow_abbrev=False,
)
cap.add_argument("--interface", required=True)
cap.add_argument("--host", required=True)
cap.add_argument("--duration", required=True)
cap.add_argument("--output", required=True)
scan = sub.add_parser(
"scan",
help="TCP connect scan: one IP and explicit numeric ports only",
allow_abbrev=False,
)
scan.add_argument("--host", required=True)
scan.add_argument("--ports", required=True)
firmware = sub.add_parser(
"firmware-scan",
help="Local Binwalk default signature scan; no extraction",
allow_abbrev=False,
)
firmware.add_argument("--input", required=True)
serial = sub.add_parser(
"serial", help="Bounded read-only Windows serial capture", allow_abbrev=False
)
serial.add_argument("--port", required=True)
serial.add_argument("--baud", required=True)
serial.add_argument("--duration", required=True)
serial.add_argument("--output", required=True)
serial.add_argument(
"--dtr-enable",
action="store_true",
help="Explicitly enable DTR to match the authorized device session; default off",
)
serial.add_argument(
"--rts-enable",
action="store_true",
help="Explicitly enable RTS to match the authorized device session; default off",
)
return p.parse_args(argv)
def validate_profile(profile):
if not isinstance(profile, dict) or not isinstance(profile.get("tools", {}), dict):
raise ValidationError("profile_must_be_object_with_tools_mapping")
def walk(value):
if isinstance(value, dict):
for key, item in value.items():
normalized = (
re.sub(r"([a-z])([A-Z])", r"\1_\2", str(key))
.lower()
.replace("-", "_")
)
is_secret = normalized in SECRET_KEYS or any(
normalized.endswith("_" + word) for word in SECRET_KEYS
)
if (
is_secret
and not normalized.endswith("_env")
and item not in (None, "")
):
raise ValidationError(
"plaintext_credentials_forbidden_use_env_reference"
)
walk(item)
elif isinstance(value, list):
for item in value:
walk(item)
elif isinstance(value, str) and re.search(
r"(?i)rtsps?://|[a-z][a-z0-9+.-]*://[^/\s]*@|[?&](?:password|passwd|token|access_token|api_key|secret)=",
value,
):
raise ValidationError("credential_or_rtsp_url_forbidden_in_profile")
walk(profile)
for key, value in profile.get("tools", {}).items():
if key not in EXECUTABLES or not isinstance(value, str) or not value.strip():
raise ValidationError("tools_requires_known_tool_id_and_executable_path")
for section, keys in (
("runtimes", {"binwalk_python"}),
("libraries", {"mediainfo"}),
):
values = profile.get(section, {})
if not isinstance(values, dict) or any(
key not in keys or not isinstance(value, str) or not value.strip()
for key, value in values.items()
):
raise ValidationError("runtime_and_library_require_known_id_and_path_only")
return profile
def load_profile(path):
if path is None:
return {}
try:
profile = json.loads(Path(path).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
raise ValidationError("profile_unreadable_or_invalid_json") from None
validate_profile(profile)
# Relative tool paths belong to the profile directory, not the caller's cwd.
for section in ("tools", "runtimes", "libraries"):
for key, value in profile.get(section, {}).items():
if not Path(value).is_absolute():
profile[section][key] = str(
(Path(path).resolve().parent / value).resolve()
)
return profile
def root_candidates(roots):
"""Only enumerate filenames; never open archives or run their contents."""
for value in roots:
root = Path(value).resolve()
if not root.is_dir():
raise ValidationError("tool_root_must_be_existing_directory")
count = 0
for current, dirs, files in os.walk(root, followlinks=False):
depth = len(Path(current).relative_to(root).parts)
dirs[:] = (
sorted(
d
for d in dirs
if not d.startswith(".") and not (Path(current) / d).is_symlink()
)
if depth < 4
else []
)
for name in sorted(files):
count += 1
if count > 20000:
return
yield Path(current) / name
def resolve_tool(tool_id, profile, roots):
names = EXECUTABLES[tool_id]
configured = profile.get("tools", {}).get(tool_id)
if configured:
path = Path(configured).resolve()
if path.name.lower() not in names:
raise ValidationError(
"unexpected_executable_name_no_installers_or_wrappers"
)
if not path.is_file():
raise ToolMissing(tool_id)
return str(path)
# Explicit roots precede PATH and require extracted, exact-name executables.
for path in root_candidates(roots):
if path.name.lower() in names and path.is_file():
return str(path.resolve())
for name in names:
found = shutil.which(name)
if found and Path(found).name.lower() in names:
return str(Path(found).resolve())
raise ToolMissing(tool_id)
def configured_local_path(value, tool_id, name_pattern):
if not isinstance(value, str) or not value:
raise ToolMissing(tool_id)
if (
"://" in value
or value.startswith(("\\\\", "//"))
or re.search(r"[\x00-\x1f]", value)
):
raise ValidationError("runtime_and_library_require_local_file")
path = Path(value).resolve()
if not re.fullmatch(name_pattern, path.name, re.IGNORECASE):
raise ValidationError("unexpected_runtime_or_library_name")
if not path.is_file():
raise ToolMissing(tool_id)
return str(path)
def command_prefix(tool_id, profile, roots):
if tool_id == "binwalk" and profile.get("runtimes", {}).get("binwalk_python"):
python = configured_local_path(
profile["runtimes"]["binwalk_python"],
"binwalk",
r"python(?:3(?:\.\d+)?)?(?:\.exe)?",
)
return [python, "-I", "-m", "binwalk"]
if tool_id == "mediainfo":
library = configured_local_path(
profile.get("libraries", {}).get("mediainfo"),
"mediainfo",
r"MediaInfo\.dll",
)
return [
sys.executable,
"-I",
str(Path(__file__).resolve()),
"_media-info-worker",
"--library",
library,
]
return [resolve_tool(tool_id, profile, roots)]
def bounded_int(value, low, high, error):
if not re.fullmatch(r"[0-9]+", str(value)) or not low <= int(value) <= high:
raise ValidationError(error)
return int(value)
def host_ip(value):
try:
if "%" in value:
raise ValueError
address = ipaddress.ip_address(value)
if (
address.is_multicast
or address.is_unspecified
or str(address) == "255.255.255.255"
):
raise ValueError
return str(address)
except ValueError:
raise ValidationError(
"one_explicit_unicast_ip_required_no_hostname_or_cidr"
) from None
def local_input(value):
if (
re.search(r"[\x00-\x1f]", value)
or "://" in value
or value.startswith(("\\\\", "//"))
):
raise ValidationError("local_regular_file_required")
path = Path(value).resolve()
if not path.is_file():
raise ValidationError("local_input_file_missing")
return str(path)
def new_output(value):
if (
re.search(r"[\x00-\x1f]", value)
or "://" in value
or value.startswith(("\\\\", "//"))
):
raise ValidationError("local_output_path_required")
path = Path(value).resolve()
if path.exists() or not path.parent.is_dir():
raise ValidationError("output_must_be_new_file_in_existing_directory")
return str(path)
def redact(text, secrets=()):
value = str(text)
for secret in sorted((str(s) for s in secrets if s), key=len, reverse=True):
value = value.replace(secret, "[REDACTED]")
value = re.sub(r"(?i)rtsps?://[^\s\"<>]+", "[RTSP_URL_REDACTED]", value)
value = re.sub(
r"(?i)(password|passwd|pwd|token|secret|authorization)\s*[:=]\s*[^\s,;]+",
r"\1=[REDACTED]",
value,
)
return value
@dataclass
class Plan:
command: str
tool_id: str
argv: list[str] = field(repr=False)
timeout: int
metadata: dict = field(default_factory=dict)
secrets: tuple[str, ...] = field(default=(), repr=False)
sensitive_argument: str | None = field(default=None, repr=False)
def public(self):
# Only the RTSP argument contains values taken from its credentials/query.
# Replacing a short token in fixed flags or executable paths corrupts plans.
safe = [
"[RTSP_URL_FROM_ENV]" if a == self.sensitive_argument else redact(a)
for a in self.argv
]
return {
"command": self.command,
"tool_id": self.tool_id,
"argv": safe,
"timeout_seconds": self.timeout,
**self.metadata,
}
def build_plan(args, profile, environ):
validate_profile(profile)
timeout = (
bounded_int(args.timeout, 1, 3600, "timeout_must_be_1_to_3600_seconds")
if args.timeout is not None
else 60
)
command = args.command
secrets = ()
sensitive = None
meta = {}
if command == "probe":
tool_id = "ffprobe"
entries = (
"stream=" + ",".join(STREAM_FIELDS) + ":format=" + ",".join(FORMAT_FIELDS)
)
tail = [
"-v",
"quiet",
"-show_entries",
entries,
"-of",
"json",
"-probesize",
"5000000",
"-analyzeduration",
"5000000",
]
if args.rtsp_env:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", args.rtsp_env):
raise ValidationError("invalid_environment_variable_name")
url = environ.get(args.rtsp_env, "")
try:
parsed = urlsplit(url)
if (
not url
or re.search(r"[\x00-\x20\x7f]", url)
or parsed.scheme not in ("rtsp", "rtsps")
or not parsed.hostname
or parsed.port == 0
or parsed.fragment
):
raise ValueError
# Accessing .port validates the valid range as well.
_ = parsed.port
except ValueError:
raise ValidationError(
"environment_must_contain_complete_rtsp_url"
) from None
secrets = tuple(
filter(
None,
(
url,
parsed.username,
parsed.password,
unquote(parsed.password or ""),
*(v for _, v in parse_qsl(parsed.query)),
),
)
)
sensitive = url
meta["rtsp_env"] = args.rtsp_env
tail += [
"-rtsp_transport",
args.transport,
"-rw_timeout",
str(timeout * 1000000),
"-i",
url,
]
else:
tail += ["-protocol_whitelist", "file", "-i", local_input(args.input)]
elif command == "media-info":
tool_id = "mediainfo"
tail = ["--input", local_input(args.input)]
elif command == "analyze-pcap":
tool_id = "tshark"
limit = bounded_int(
args.max_packets, 1, 10000, "max_packets_must_be_1_to_10000"
)
tail = [
"-n",
"-r",
local_input(args.input),
"-c",
str(limit),
"-T",
"fields",
"-E",
"header=y",
"-E",
"separator=/t",
"-E",
"quote=d",
]
for item in PCAP_FIELDS:
tail += ["-e", item]
meta["max_packets"] = limit
elif command in ("capture", "serial"):
duration = bounded_int(
args.duration, 1, 600, "duration_must_be_1_to_600_seconds"
)
timeout = duration + 10 if args.timeout is None else timeout
if timeout < duration + 5:
raise ValidationError("timeout_must_exceed_duration_by_at_least_5_seconds")
output = new_output(args.output)
meta.update(
{
"output": output,
"duration_seconds": duration,
"artifact_may_contain_sensitive_data": True,
}
)
if command == "capture":
tool_id = "dumpcap"
interface = args.interface
if (
not interface
or interface.startswith("-")
or "://" in interface
or re.search(r"[\x00-\x1f]", interface)
):
raise ValidationError("explicit_local_interface_required")
ip = host_ip(args.host)
tail = [
"-q",
"-i",
interface,
"-f",
"host " + ip,
"-s",
"256",
"-a",
"duration:" + str(duration),
"-a",
"filesize:102400",
"-w",
output,
]
meta["host"] = ip
else:
tool_id = "powershell"
if not re.fullmatch(r"COM[1-9][0-9]{0,3}", args.port, re.IGNORECASE):
raise ValidationError("explicit_windows_com_port_required")
baud = bounded_int(args.baud, 1, 4000000, "baud_must_be_1_to_4000000")
if Path(output + ".raw.bin").exists():
raise ValidationError("serial_raw_output_already_exists")
if (
not SERIAL_SCRIPT.is_file()
or hashlib.sha256(SERIAL_SCRIPT.read_bytes()).hexdigest()
!= SERIAL_SHA256
):
raise ValidationError(
"vendor_serial_script_missing_or_checksum_changed"
)
tail = [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-File",
str(SERIAL_SCRIPT),
"-Port",
args.port.upper(),
"-BaudRate",
str(baud),
"-DurationSeconds",
str(duration),
"-LogPath",
output,
]
if args.dtr_enable:
tail.append("-DtrEnable")
if args.rts_enable:
tail.append("-RtsEnable")
meta["raw_output"] = output + ".raw.bin"
meta.update(dtr_enabled=args.dtr_enable, rts_enabled=args.rts_enable)
elif command == "scan":
tool_id = "nmap"
ip = host_ip(args.host)
if not re.fullmatch(r"[0-9]+(?:,[0-9]+)*", args.ports):
raise ValidationError("ports_require_explicit_comma_separated_numbers")
ports = sorted(
{
bounded_int(x, 1, 65535, "port_out_of_range")
for x in args.ports.split(",")
}
)
if len(ports) > 128:
raise ValidationError("at_most_128_explicit_ports_allowed")
tail = [
"-sT",
"-n",
"-Pn",
"--max-retries",
"1",
"--host-timeout",
str(max(1, timeout - 2)) + "s",
"-p",
",".join(map(str, ports)),
"-oX",
"-",
]
if ":" in ip:
tail += ["-6"]
tail += [ip]
meta.update({"host": ip, "ports": ports})
elif command == "firmware-scan":
tool_id = "binwalk"
# Both Binwalk 2.x and 3.x default to signature scanning. No -e/-M.
tail = [local_input(args.input)]
else:
raise ValidationError("unsupported_execution_command")
prefix = command_prefix(tool_id, profile, args.tool_root)
return Plan(command, tool_id, prefix + tail, timeout, meta, secrets, sensitive)
def safe_data(plan, stdout):
if plan.command == "probe":
source = json.loads(stdout)
data = {
"streams": [
{
k: v
for k, v in row.items()
if k in STREAM_FIELDS and isinstance(v, (str, int, float))
}
for row in source.get("streams", [])
if isinstance(row, dict)
],
"format": {
k: v
for k, v in source.get("format", {}).items()
if k in FORMAT_FIELDS and isinstance(v, (str, int, float))
},
}
# Redact decoded values before JSON escaping (passwords may include quotes).
for row in data["streams"] + [data["format"]]:
for key, value in row.items():
if isinstance(value, str):
# Numeric measurements are structured data, not echoed URL data.
# Preserve valid numbers even when a query token is e.g. "1".
numeric = key not in (
"codec_name",
"codec_type",
"format_name",
) and re.fullmatch(r"-?\d+(?:\.\d+)?(?:/\d+)?", value)
row[key] = value if numeric else redact(value, plan.secrets)
return data
if plan.command == "media-info":
source = json.loads(stdout)
data = {"version": redact(str(source.get("version", "")))}
for group, fields in MEDIAINFO_FIELDS.items():
rows = (
[source.get(group, {})] if group == "general" else source.get(group, [])
)
filtered = [
{
key: redact(str(value))
for key, value in row.items()
if key in fields and isinstance(value, (str, int, float))
}
for row in rows[:100]
if isinstance(row, dict)
]
data[group] = (
filtered[0]
if group == "general" and filtered
else {}
if group == "general"
else filtered
)
return data
if plan.command == "analyze-pcap":
rows = csv.DictReader(io.StringIO(stdout), delimiter="\t")
return {
"packets": [
{
k: redact(str(v or ""), plan.secrets)
for k, v in row.items()
if k in PCAP_FIELDS
}
for _, row in zip(range(plan.metadata["max_packets"]), rows)
]
}
if plan.command == "scan":
root = ET.fromstring(stdout)
requested_ports = list(plan.metadata["ports"])
hosts = []
for host in root.findall("host"):
state = host.find("status")
ports = []
for port in host.findall("ports/port"):
status = port.find("state")
ports.append(
{
"port": port.get("portid"),
"protocol": port.get("protocol"),
"state": status.get("state") if status is not None else None,
}
)
aggregates = []
for group in host.findall("ports/extraports"):
reasons = [
{
"reason": reason.get("reason"),
"count": int(reason.get("count", "0")),
}
for reason in group.findall("extrareasons")
]
aggregates.append(
{
"state": group.get("state"),
"count": int(group.get("count", "0")),
"reasons": reasons,
}
)
# Aggregated groups do not identify the state of individual ports.
# Count only explicit TCP port states when reporting attribution.
reported = {
int(port["port"])
for port in ports
if port["protocol"] == "tcp" and port["state"]
}
missing = [port for port in requested_ports if port not in reported]
coverage = (
"complete"
if not missing
else "partial"
if len(missing) < len(requested_ports)
else "none"
)
hosts.append(
{
"state": state.get("state") if state is not None else None,
"ports": ports,
"aggregate_states": aggregates,
"ports_not_individually_reported": missing,
"individual_port_coverage": coverage,
}
)
return {
"requested_ports": requested_ports,
"host_results_present": bool(hosts),
"hosts": hosts,
}
if plan.command == "firmware-scan":
return {
"signature_report": redact(stdout[:64000]),
"output_truncated": len(stdout) > 64000,
}
return {}
def process_lifecycle():
import importlib.util
name = "_artemis_device_test_process_lifecycle"
if name not in sys.modules:
spec = importlib.util.spec_from_file_location(
name, Path(__file__).with_name("process_lifecycle.py")
)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return sys.modules[name]
PROCESS = process_lifecycle()
def _run_in_windows_job(argv, **kwargs):
return PROCESS.run(argv, **kwargs)
def run_process(argv, tool_id, **kwargs):
def run(**options):
if os.name == "nt":
return _run_in_windows_job(argv, **options)
# This plugin targets Windows; fail closed rather than claim containment elsewhere.
raise PROCESS.ManagedProcessError("process_ownership_requires_windows")
if tool_id != "binwalk":
return run(**kwargs)
env = dict(kwargs.pop("env", os.environ))
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
with tempfile.TemporaryDirectory(prefix="artemis-binwalk-") as config:
env["XDG_CONFIG_HOME"] = config
return run(env=env, **kwargs)
def media_info_worker(argv):
"""Private fixed DLL worker; parent process owns the hard timeout."""
p = SafeParser(allow_abbrev=False)
p.add_argument("--library", required=True)
source = p.add_mutually_exclusive_group(required=True)
source.add_argument("--input")
source.add_argument("--version", action="store_true")
try:
args = p.parse_args(argv)
library = configured_local_path(args.library, "mediainfo", r"MediaInfo\.dll")
media = local_input(args.input) if args.input else None
import ctypes as C
lib = C.WinDLL(library)
signatures = {
"New": ([], C.c_void_p),
"Delete": ([C.c_void_p], None),
"Option": ([C.c_void_p, C.c_wchar_p, C.c_wchar_p], C.c_wchar_p),
"Open": ([C.c_void_p, C.c_wchar_p], C.c_size_t),
"Close": ([C.c_void_p], None),
"Get": (
[C.c_void_p, C.c_int, C.c_size_t, C.c_wchar_p, C.c_int, C.c_int],
C.c_wchar_p,
),
"Count_Get": ([C.c_void_p, C.c_int, C.c_size_t], C.c_size_t),
}
for name, (argtypes, restype) in signatures.items():
function = getattr(lib, "MediaInfo_" + name)
function.argtypes, function.restype = argtypes, restype
# MediaInfo 21.09 enables this option for every nonempty value,
# including "No". Require its explicit disabled readback before parsing.
lib.MediaInfo_Option(None, "Internet", "")
if lib.MediaInfo_Option(None, "Internet_Get", "") != "":
raise ValueError
version = lib.MediaInfo_Option(None, "Info_Version", "") or ""
if args.version:
print(redact(version))
return 0
handle = lib.MediaInfo_New()
if not handle:
raise ValueError
try:
lib.MediaInfo_Option(handle, "Internet", "")
if lib.MediaInfo_Option(handle, "Internet_Get", "") != "":
raise ValueError
if lib.MediaInfo_Open(handle, media) == 0:
raise ValueError
data = {"version": version}
for kind, group in enumerate(("general", "video", "audio")):
count = min(
100, lib.MediaInfo_Count_Get(handle, kind, C.c_size_t(-1).value)
)
rows = [
{
key: lib.MediaInfo_Get(handle, kind, index, key, 1, 0) or ""
for key in MEDIAINFO_FIELDS[group]
}
for index in range(count)
]
data[group] = (
rows[0]
if group == "general" and rows
else {}
if group == "general"
else rows
)
print(
json.dumps(
safe_data(Plan("media-info", "mediainfo", [], 1), json.dumps(data)),
ensure_ascii=True,
)
)
return 0
finally:
lib.MediaInfo_Close(handle)
lib.MediaInfo_Delete(handle)
except (ValidationError, ToolMissing, OSError, ValueError, AttributeError):
# Never return exception text from native libraries or media metadata.
print(json.dumps({"error_code": "mediainfo_library_or_input_unavailable"}))
return 1
def execute(plan):
result = {
"execution_status": "not_started",
"test_verdict": "not_evaluated",
**plan.public(),
}
child_env = os.environ.copy()
# FFREPORT can silently persist full RTSP command lines even with quiet logs.
child_env.pop("FFREPORT", None)
if "rtsp_env" in plan.metadata:
child_env.pop(plan.metadata["rtsp_env"], None)
try:
completed = run_process(
list(plan.argv),
plan.tool_id,
shell=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL
if plan.command in ("capture", "serial")
else subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
timeout=plan.timeout,
check=False,
env=child_env,
)
result["returncode"] = completed.returncode
result["execution_status"] = (
"completed" if completed.returncode == 0 else "failed"
)
result["stderr_suppressed"] = True
if completed.returncode == 0:
try:
result["data"] = safe_data(plan, completed.stdout or "")
except (ValueError, TypeError, AttributeError, ET.ParseError):
result["execution_status"] = "output_invalid"
result["error_code"] = "tool_output_not_expected_format"
else:
result["error_code"] = "tool_returned_nonzero_sensitive_output_suppressed"
except subprocess.TimeoutExpired:
result.update(
execution_status="timed_out",
error_code="process_timeout_partial_output_suppressed",
)
except PROCESS.ManagedProcessError as exc:
status = (
"cleanup_unconfirmed"
if exc.error_code == "process_cleanup_unconfirmed"
else "output_limit"
if exc.error_code == "process_output_limit"
else "unknown"
if exc.may_have_started
else "launch_failed"
)
result.update(
execution_status=status,
error_code=exc.error_code,
may_have_started=exc.may_have_started,
)
except OSError:
# Includes config-directory cleanup after execution; absence of phase proof is unknown.
result.update(
execution_status="unknown",
error_code="process_io_unconfirmed",
may_have_started=True,
)
return result
def inventory(profile, roots, check_versions=False):
validate_profile(profile)
catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
rows = []
for entry in catalog["tools"]:
row = dict(entry)
tool_id = entry["tool_id"]
if tool_id not in EXECUTABLES and tool_id != "mediainfo":
row["availability"] = {
"mcp": "mcp-check-required",
"orchestrator": "orchestrator-context",
}.get(entry.get("interface"), "manual-interface")
else:
try:
prefix = command_prefix(tool_id, profile, roots)
if tool_id == "mediainfo":
row.update(availability="library-found", library=prefix[-1])
elif tool_id == "binwalk" and len(prefix) > 1:
row.update(
availability="python-module-configured",
executable=prefix[0],
module="binwalk",
)
else:
row.update(availability="executable-found", executable=prefix[0])
if check_versions:
# PowerShell -Version requires a version argument; use help only.
flag = (
"-?"
if tool_id == "powershell"
else VERSION_FLAGS.get(tool_id, "--version")
)
try:
proc = run_process(
prefix + [flag],
tool_id,
shell=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=5,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
first_line = next(
(
line.strip()
for line in (proc.stdout or "").splitlines()
if line.strip()
),
"",
)
row["version_check"] = {
"execution_status": "completed"
if proc.returncode == 0
else "failed",
"returncode": proc.returncode,
"first_line": redact(first_line[:240]),
}
except (OSError, subprocess.TimeoutExpired):
row["version_check"] = {"execution_status": "unavailable"}
except ToolMissing:
patterns = entry.get("package_name_patterns", [])
found = next(
(
p.name
for p in root_candidates(roots)
if any(
fragment.lower() in p.name.lower() for fragment in patterns
)
and p.suffix.lower() in (".zip", ".7z")
),
None,
)
row["availability"] = "package-only" if found else "missing"
if found:
row["package_filename"] = found
rows.append(row)
return {
"execution_status": "completed",
"test_verdict": "not_evaluated",
"tools": rows,
"note": "Executable discovery is not an installation, driver, privilege, connection or test-pass check.",
}
def main(argv=None):
try:
args = parse_args(argv)
profile = load_profile(args.profile)
if args.command in ("list", "doctor"):
result = inventory(
profile,
args.tool_root,
check_versions=bool(
getattr(args, "check_versions", False) and not args.dry_run
),
)
else:
plan = build_plan(args, profile, os.environ)
result = (
{
"execution_status": "planned",
"test_verdict": "not_evaluated",
**plan.public(),
}
if args.dry_run
else execute(plan)
)
code = 0 if result["execution_status"] in ("planned", "completed") else 1
except ValidationError as error:
result = {
"execution_status": "invalid_arguments",
"test_verdict": "not_evaluated",
"error_code": str(error),
}
code = 2
except ToolMissing as error:
result = {
"execution_status": "tool_missing",
"test_verdict": "not_evaluated",
"tool_id": str(error),
"error_code": "configure_trusted_executable_via_profile_tools_path_or_tool_root",
}
code = 3
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(
media_info_worker(sys.argv[2:])
if sys.argv[1:2] == ["_media-info-worker"]
else main()
)