feat(lab): add isolated iPhone capture evidence
This commit is contained in:
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,69 @@
|
||||
# iPhone / LixelGO capture lab
|
||||
|
||||
This is an isolated evidence tool for the XGRIDS K1 plugin. It is not a Mission
|
||||
Core runtime dependency and must not be bundled without a separate GPL license
|
||||
review.
|
||||
|
||||
## Install without Xcode
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
|
||||
```
|
||||
|
||||
The environment is created under the lab directory. It pins
|
||||
`pymobiledevice3==9.36.0`; it does not modify the system Python, Homebrew, the
|
||||
Mission Core `.venv`, or the root lockfile.
|
||||
|
||||
## USB preflight
|
||||
|
||||
Connect exactly one iPhone with a data-capable USB cable, unlock it, confirm the
|
||||
Mac accessory prompt, and tap **Trust This Computer** on the iPhone. Then run:
|
||||
|
||||
```bash
|
||||
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
|
||||
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
|
||||
```
|
||||
|
||||
The preflight prints only a device count. It never prints or stores the iPhone
|
||||
UDID or serial number.
|
||||
|
||||
## Bounded network capture
|
||||
|
||||
Run this only after the post-refactor exact-profile K1 regression in ADR 0004:
|
||||
|
||||
```bash
|
||||
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
|
||||
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
|
||||
--duration 120
|
||||
```
|
||||
|
||||
The capture uses the iPhone `pcapd` service over USB, suppresses packet hex on
|
||||
stdout, and writes private artifacts under ignored
|
||||
`sessions/iphone-k1-observation/`. It is intentionally bounded to 5–600 seconds.
|
||||
Each successful run retains the original metadata-bearing `PCAPNG` and creates a
|
||||
classic Ethernet `PCAP` that the macOS bundled `tcpdump` can read. The redacted
|
||||
manifest records the validated packet count and hashes both artifacts.
|
||||
The default refuses a dirty repository; `--allow-dirty` records a source-tree
|
||||
fingerprint when an explicitly approved lab run cannot wait for a commit.
|
||||
|
||||
Operator events may be recorded while the capture is active and can finish
|
||||
after the bounded network process exits. Preserve the original capture manifest
|
||||
and create a separate integrity inventory after the operator timeline is closed:
|
||||
|
||||
```bash
|
||||
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
|
||||
sessions/iphone-k1-observation/<session>
|
||||
```
|
||||
|
||||
The sealer re-hashes every manifest artifact, adds the manifest and optional
|
||||
`operator-events.private.jsonl`, and writes an ignored mode-`0600`
|
||||
`integrity.private.json`. It never edits the original manifest or raw capture.
|
||||
|
||||
Do not add `--bluetooth` unless Apple's Bluetooth diagnostic logging profile is
|
||||
already installed on the iPhone and the operator explicitly approved that
|
||||
configuration change. The script never installs the profile.
|
||||
|
||||
Wireshark is optional and is not required to record PCAP/PCAPNG. Full Xcode is
|
||||
also not required for this path.
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pcapng import FileScanner
|
||||
from pcapng.blocks import EnhancedPacket
|
||||
from preflight import (
|
||||
pymobiledevice3_binary,
|
||||
pymobiledevice3_version,
|
||||
usb_device_count,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation"
|
||||
SAFE_LABEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def git_output(*args: str) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def source_snapshot() -> dict[str, Any]:
|
||||
revision = git_output("rev-parse", "HEAD").decode().strip()
|
||||
status = git_output("status", "--porcelain=v1", "--untracked-files=all")
|
||||
dirty = bool(status.strip())
|
||||
digest = hashlib.sha256()
|
||||
digest.update(revision.encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(status)
|
||||
if dirty:
|
||||
digest.update(git_output("diff", "--binary", "HEAD"))
|
||||
untracked = git_output("ls-files", "--others", "--exclude-standard", "-z")
|
||||
for raw_name in filter(None, untracked.split(b"\0")):
|
||||
relative = raw_name.decode("utf-8", errors="surrogateescape")
|
||||
path = REPO_ROOT / relative
|
||||
digest.update(raw_name)
|
||||
digest.update(b"\0")
|
||||
if path.is_file():
|
||||
digest.update(sha256_file(path).encode())
|
||||
return {
|
||||
"git_revision": revision,
|
||||
"source_tree_dirty": dirty,
|
||||
"source_tree_fingerprint_sha256": digest.hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def terminate(process: subprocess.Popen[bytes]) -> int:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
return int(process.returncode or 0)
|
||||
|
||||
|
||||
def artifact_record(path: Path, kind: str, session_dir: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file() or path.stat().st_size == 0:
|
||||
return None
|
||||
path.chmod(0o600)
|
||||
return {
|
||||
"kind": kind,
|
||||
"relative_path": str(path.relative_to(session_dir)),
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def convert_pcapng_to_pcap(source: Path, destination: Path) -> int:
|
||||
"""Create a classic Ethernet PCAP that macOS's bundled tcpdump can read."""
|
||||
packet_count = 0
|
||||
with source.open("rb") as source_stream, destination.open("xb") as destination_stream:
|
||||
destination_stream.write(
|
||||
struct.pack(
|
||||
"<IHHIIII",
|
||||
0xA1B2C3D4,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
262_144,
|
||||
1,
|
||||
)
|
||||
)
|
||||
for block in FileScanner(source_stream):
|
||||
if not isinstance(block, EnhancedPacket):
|
||||
continue
|
||||
packet_data = bytes(block.packet_data)
|
||||
captured_length = int(block.captured_len)
|
||||
packet_length = int(block.packet_len)
|
||||
if captured_length != len(packet_data) or packet_length < captured_length:
|
||||
raise ValueError("invalid enhanced-packet lengths")
|
||||
|
||||
timestamp = float(block.timestamp)
|
||||
seconds = int(timestamp)
|
||||
microseconds = int(round((timestamp - seconds) * 1_000_000))
|
||||
if microseconds >= 1_000_000:
|
||||
seconds += 1
|
||||
microseconds -= 1_000_000
|
||||
destination_stream.write(
|
||||
struct.pack(
|
||||
"<IIII",
|
||||
seconds,
|
||||
microseconds,
|
||||
captured_length,
|
||||
packet_length,
|
||||
)
|
||||
)
|
||||
destination_stream.write(packet_data)
|
||||
packet_count += 1
|
||||
destination.chmod(0o600)
|
||||
return packet_count
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bounded, redacted USB iPhone capture for the Mission Core K1 lab"
|
||||
)
|
||||
parser.add_argument("--duration", type=int, default=120, help="capture seconds (5..600)")
|
||||
parser.add_argument("--label", default="lixelgo-observe", help="safe session label")
|
||||
parser.add_argument("--process", help="optional iOS process filter for network capture")
|
||||
parser.add_argument("--interface", help="optional iOS interface filter for network capture")
|
||||
parser.add_argument(
|
||||
"--bluetooth",
|
||||
action="store_true",
|
||||
help="also capture iPhone Bluetooth HCI; requires Apple's logging profile",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acknowledge-ios-bluetooth-profile",
|
||||
action="store_true",
|
||||
help="confirm the operator already installed Apple's Bluetooth logging profile",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-dirty",
|
||||
action="store_true",
|
||||
help="record an explicit dirty-tree fingerprint instead of refusing the capture",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not 5 <= args.duration <= 600:
|
||||
raise SystemExit("--duration must be between 5 and 600 seconds")
|
||||
if not SAFE_LABEL.fullmatch(args.label):
|
||||
raise SystemExit("--label must use lowercase ASCII letters, digits, and hyphens")
|
||||
if args.bluetooth and not args.acknowledge_ios_bluetooth_profile:
|
||||
raise SystemExit(
|
||||
"Bluetooth capture requires --acknowledge-ios-bluetooth-profile; "
|
||||
"the script never installs the profile"
|
||||
)
|
||||
|
||||
os.umask(0o077)
|
||||
binary = pymobiledevice3_binary()
|
||||
if usb_device_count(binary) != 1:
|
||||
raise SystemExit("exactly one trusted USB iPhone must be connected")
|
||||
snapshot = source_snapshot()
|
||||
if snapshot["source_tree_dirty"] and not args.allow_dirty:
|
||||
raise SystemExit(
|
||||
"repository is dirty; commit the milestone or pass --allow-dirty "
|
||||
"to record its fingerprint"
|
||||
)
|
||||
|
||||
session_id = (
|
||||
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"_{args.label}_{secrets.token_hex(2)}"
|
||||
)
|
||||
session_dir = SESSION_ROOT / session_id
|
||||
capture_dir = session_dir / "captures"
|
||||
capture_dir.mkdir(parents=True, mode=0o700)
|
||||
network_pcapng_path = capture_dir / "iphone-network.pcapng"
|
||||
network_pcap_path = capture_dir / "iphone-network.pcap"
|
||||
bluetooth_path = capture_dir / "iphone-bluetooth.pcapng"
|
||||
stderr_path = session_dir / "capture.stderr.log"
|
||||
manifest_path = session_dir / "manifest.redacted.json"
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"schema": "missioncore.xgrids-k1/iphone-observation-manifest/v1alpha1",
|
||||
"session_id": session_id,
|
||||
"status": "starting",
|
||||
"started_at_utc": utc_now(),
|
||||
"started_monotonic_ns": time.monotonic_ns(),
|
||||
"requested_duration_seconds": args.duration,
|
||||
"capture_backend": "pymobiledevice3-9.36.0/com.apple.pcapd",
|
||||
"pymobiledevice3_version": pymobiledevice3_version(binary),
|
||||
"network_capture": True,
|
||||
"bluetooth_capture": args.bluetooth,
|
||||
"network_process_filter": args.process,
|
||||
"network_interface_filter": args.interface,
|
||||
"device_identity_recorded": False,
|
||||
"credentials_recorded_in_manifest": False,
|
||||
"source": snapshot,
|
||||
"artifacts": [],
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
|
||||
network_command = [binary, "--no-color", "pcap", "--out", str(network_pcapng_path)]
|
||||
if args.process:
|
||||
network_command.extend(["--process", args.process])
|
||||
if args.interface:
|
||||
network_command.extend(["--interface", args.interface])
|
||||
commands = [("network", network_command)]
|
||||
if args.bluetooth:
|
||||
commands.append(
|
||||
(
|
||||
"bluetooth",
|
||||
[binary, "--no-color", "btlogger", str(bluetooth_path), "--format", "pcapng"],
|
||||
)
|
||||
)
|
||||
|
||||
processes: list[tuple[str, subprocess.Popen[bytes]]] = []
|
||||
exit_codes: dict[str, int] = {}
|
||||
interrupted = False
|
||||
failure: str | None = None
|
||||
with stderr_path.open("wb") as error_log:
|
||||
stderr_path.chmod(0o600)
|
||||
try:
|
||||
for name, command in commands:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=error_log,
|
||||
)
|
||||
processes.append((name, process))
|
||||
time.sleep(1)
|
||||
early = [name for name, process in processes if process.poll() is not None]
|
||||
if early:
|
||||
raise RuntimeError(f"capture service exited early: {', '.join(early)}")
|
||||
|
||||
deadline = time.monotonic() + args.duration
|
||||
while time.monotonic() < deadline:
|
||||
ended = [name for name, process in processes if process.poll() is not None]
|
||||
if ended:
|
||||
raise RuntimeError(f"capture service stopped unexpectedly: {', '.join(ended)}")
|
||||
time.sleep(min(0.25, deadline - time.monotonic()))
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
except RuntimeError as exc:
|
||||
failure = str(exc)
|
||||
finally:
|
||||
for name, process in processes:
|
||||
exit_codes[name] = terminate(process)
|
||||
|
||||
network_packet_count: int | None = None
|
||||
if failure is None:
|
||||
try:
|
||||
network_packet_count = convert_pcapng_to_pcap(
|
||||
network_pcapng_path,
|
||||
network_pcap_path,
|
||||
)
|
||||
if network_packet_count == 0:
|
||||
failure = "network capture validation found no packets"
|
||||
except Exception as exc: # noqa: BLE001 - third-party parser errors vary by version
|
||||
failure = f"network capture validation failed: {type(exc).__name__}"
|
||||
|
||||
artifacts = [
|
||||
artifact_record(network_pcapng_path, "iphone-network-pcapng-raw", session_dir),
|
||||
artifact_record(network_pcap_path, "iphone-network-pcap-compatible", session_dir),
|
||||
artifact_record(bluetooth_path, "iphone-bluetooth-hci-pcapng", session_dir),
|
||||
artifact_record(stderr_path, "capture-stderr", session_dir),
|
||||
]
|
||||
manifest.update(
|
||||
{
|
||||
"status": "failed" if failure else "completed",
|
||||
"interrupted_by_operator": interrupted,
|
||||
"failure": failure,
|
||||
"ended_at_utc": utc_now(),
|
||||
"ended_monotonic_ns": time.monotonic_ns(),
|
||||
"process_exit_codes": exit_codes,
|
||||
"network_packet_count": network_packet_count,
|
||||
"artifacts": [item for item in artifacts if item is not None],
|
||||
}
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
print(f"session: {session_dir}")
|
||||
print(f"status: {manifest['status']}")
|
||||
return 1 if failure else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pymobiledevice3_binary() -> str:
|
||||
sibling = Path(sys.executable).with_name("pymobiledevice3")
|
||||
if sibling.is_file():
|
||||
return str(sibling)
|
||||
discovered = shutil.which("pymobiledevice3")
|
||||
if discovered is None:
|
||||
raise RuntimeError("pymobiledevice3 is not installed in this environment")
|
||||
return discovered
|
||||
|
||||
|
||||
def pymobiledevice3_version(binary: str) -> str:
|
||||
completed = subprocess.run(
|
||||
[binary, "--no-color", "version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError("pymobiledevice3 version check failed")
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def usb_device_count(binary: str) -> int:
|
||||
completed = subprocess.run(
|
||||
[binary, "--no-color", "usbmux", "list", "--usb"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"USB discovery failed; unlock the iPhone and confirm Trust This Computer"
|
||||
)
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("USB discovery returned an unexpected response") from exc
|
||||
if not isinstance(payload, list):
|
||||
raise RuntimeError("USB discovery returned an unexpected response")
|
||||
return len(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
binary = pymobiledevice3_binary()
|
||||
version = pymobiledevice3_version(binary)
|
||||
count = usb_device_count(binary)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
print(f"BLOCKED: {exc}")
|
||||
return 2
|
||||
|
||||
print(f"pymobiledevice3: {version}")
|
||||
print(f"USB iPhone devices: {count}")
|
||||
if count == 0:
|
||||
print("BLOCKED: connect one unlocked iPhone with a data cable and confirm Trust")
|
||||
return 2
|
||||
if count > 1:
|
||||
print("BLOCKED: leave exactly one trusted iPhone connected for a redacted capture")
|
||||
return 2
|
||||
print("READY: one trusted USB iPhone is available; no device identifier was printed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "mission-core-ios-capture-lab"
|
||||
version = "0.1.0"
|
||||
description = "Isolated read-only iPhone packet-capture tooling for Mission Core lab work"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"pymobiledevice3==9.36.0",
|
||||
"python-pcapng==2.1.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Seal one completed iPhone capture without rewriting its original manifest.
|
||||
|
||||
The capture manifest is written when the bounded packet capture ends. Operator
|
||||
events may be appended after that point, so they belong in a separate derived
|
||||
integrity inventory rather than in a retroactively edited capture manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
INTEGRITY_SCHEMA = "missioncore.xgrids-k1/iphone-session-integrity/v1alpha1"
|
||||
CAPTURE_MANIFEST = "manifest.redacted.json"
|
||||
INTEGRITY_INVENTORY = "integrity.private.json"
|
||||
OPTIONAL_SESSION_ARTIFACTS = {
|
||||
"capture.stderr.log": "capture-stderr",
|
||||
"operator-events.private.jsonl": "operator-events",
|
||||
}
|
||||
|
||||
|
||||
class SessionIntegrityError(ValueError):
|
||||
"""The session cannot be safely verified or sealed."""
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _load_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError(f"cannot read JSON object: {path.name}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError(f"JSON root must be an object: {path.name}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_artifact_path(session_dir: Path, relative_path: object) -> Path:
|
||||
if not isinstance(relative_path, str) or not relative_path:
|
||||
raise SessionIntegrityError("artifact relative_path must be a non-empty string")
|
||||
relative = Path(relative_path)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise SessionIntegrityError("artifact path must stay inside the session")
|
||||
path = session_dir / relative
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SessionIntegrityError(f"artifact is missing or is a symlink: {relative_path}")
|
||||
return path
|
||||
|
||||
|
||||
def _record(path: Path, *, kind: str, session_dir: Path) -> dict[str, Any]:
|
||||
before = path.stat()
|
||||
digest = sha256_file(path)
|
||||
after = path.stat()
|
||||
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
||||
raise SessionIntegrityError(f"artifact changed while hashing: {path.name}")
|
||||
return {
|
||||
"kind": kind,
|
||||
"relative_path": str(path.relative_to(session_dir)),
|
||||
"size_bytes": after.st_size,
|
||||
"sha256": digest,
|
||||
"mode_octal": f"{after.st_mode & 0o777:04o}",
|
||||
}
|
||||
|
||||
|
||||
def build_inventory(session_dir: Path) -> dict[str, Any]:
|
||||
"""Verify a completed capture and return a derived integrity inventory."""
|
||||
session_dir = session_dir.expanduser().resolve()
|
||||
manifest_path = session_dir / CAPTURE_MANIFEST
|
||||
if manifest_path.is_symlink() or not manifest_path.is_file():
|
||||
raise SessionIntegrityError(f"missing {CAPTURE_MANIFEST}")
|
||||
|
||||
manifest = _load_object(manifest_path)
|
||||
if manifest.get("status") != "completed":
|
||||
raise SessionIntegrityError("only a completed capture can be sealed")
|
||||
session_id = manifest.get("session_id")
|
||||
if not isinstance(session_id, str) or session_id != session_dir.name:
|
||||
raise SessionIntegrityError("manifest session_id does not match the directory")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or not artifacts:
|
||||
raise SessionIntegrityError("capture manifest has no artifacts")
|
||||
|
||||
verified: list[dict[str, Any]] = []
|
||||
for item in artifacts:
|
||||
if not isinstance(item, dict):
|
||||
raise SessionIntegrityError("manifest artifact must be an object")
|
||||
path = _safe_artifact_path(session_dir, item.get("relative_path"))
|
||||
actual = _record(
|
||||
path,
|
||||
kind=str(item.get("kind") or "capture-manifest-artifact"),
|
||||
session_dir=session_dir,
|
||||
)
|
||||
if item.get("size_bytes") != actual["size_bytes"]:
|
||||
raise SessionIntegrityError(f"artifact size mismatch: {actual['relative_path']}")
|
||||
if item.get("sha256") != actual["sha256"]:
|
||||
raise SessionIntegrityError(f"artifact hash mismatch: {actual['relative_path']}")
|
||||
verified.append(actual)
|
||||
|
||||
derived = [_record(manifest_path, kind="capture-manifest", session_dir=session_dir)]
|
||||
for relative_name, kind in OPTIONAL_SESSION_ARTIFACTS.items():
|
||||
path = session_dir / relative_name
|
||||
if path.exists():
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SessionIntegrityError(
|
||||
f"optional artifact is not a regular file: {relative_name}"
|
||||
)
|
||||
derived.append(_record(path, kind=kind, session_dir=session_dir))
|
||||
|
||||
return {
|
||||
"schema": INTEGRITY_SCHEMA,
|
||||
"session_id": session_id,
|
||||
"sealed_at_utc": datetime.now(UTC).isoformat(),
|
||||
"capture_manifest_preserved": True,
|
||||
"verified_manifest_artifacts": verified,
|
||||
"derived_session_artifacts": derived,
|
||||
}
|
||||
|
||||
|
||||
def seal_session(session_dir: Path, *, overwrite: bool = False) -> Path:
|
||||
"""Write an ignored mode-0600 inventory next to the immutable capture."""
|
||||
os.umask(0o077)
|
||||
resolved = session_dir.expanduser().resolve()
|
||||
destination = resolved / INTEGRITY_INVENTORY
|
||||
if destination.exists() and not overwrite:
|
||||
raise SessionIntegrityError(f"{INTEGRITY_INVENTORY} already exists")
|
||||
inventory = build_inventory(resolved)
|
||||
temporary = resolved / f".{INTEGRITY_INVENTORY}.tmp"
|
||||
temporary.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(destination)
|
||||
destination.chmod(0o600)
|
||||
return destination
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify and seal one completed Mission Core iPhone capture session"
|
||||
)
|
||||
parser.add_argument("session", type=Path, help="completed ignored session directory")
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="replace only the derived inventory; never changes the capture manifest",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
destination = seal_session(args.session, overwrite=args.overwrite)
|
||||
except SessionIntegrityError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(f"sealed: {destination}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+1349
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user