Skip to content

Commit f503173

Browse files
committed
Add file integrity monitor for periodic manifest verification
1 parent a340aed commit f503173

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

automation_file/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from automation_file.core.config import AutomationConfig, ConfigException
3030
from automation_file.core.config_watcher import ConfigWatcher
3131
from automation_file.core.dag_executor import execute_action_dag
32+
from automation_file.core.fim import IntegrityMonitor
3233
from automation_file.core.json_store import read_action_json, write_action_json
3334
from automation_file.core.manifest import ManifestException, verify_manifest, write_manifest
3435
from automation_file.core.package_loader import PackageLoader
@@ -316,6 +317,7 @@ def __getattr__(name: str) -> Any:
316317
"verify_manifest",
317318
"AuditException",
318319
"AuditLog",
320+
"IntegrityMonitor",
319321
# Triggers
320322
"FileWatcher",
321323
"TriggerManager",

automation_file/core/fim.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""File integrity monitoring (FIM) — periodic manifest verification.
2+
3+
``IntegrityMonitor(root, manifest_path)`` runs a background thread that
4+
re-verifies the tree against a previously-written manifest at a fixed
5+
interval. When drift is detected (missing or modified files) the monitor
6+
7+
1. invokes the optional ``on_drift`` callback with the verification summary,
8+
2. emits an ``error``-level notification through the supplied
9+
:class:`~automation_file.notify.NotificationManager` (defaults to the
10+
process-wide singleton), and
11+
3. logs a single warning describing the counts.
12+
13+
This is the "watchdog" side of manifests: once a baseline has been written
14+
with :func:`write_manifest`, a monitor keeps checking that the tree still
15+
matches and alerts when it does not. Extras (new files not in the manifest)
16+
do not count as drift by default — mirrors the posture of ``verify_manifest``.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import threading
22+
from collections.abc import Callable
23+
from pathlib import Path
24+
from typing import Any
25+
26+
from automation_file.core.manifest import ManifestException, verify_manifest
27+
from automation_file.exceptions import FileAutomationException
28+
from automation_file.logging_config import file_automation_logger
29+
from automation_file.notify import NotificationManager, notification_manager
30+
31+
OnDrift = Callable[[dict[str, Any]], None]
32+
33+
34+
class IntegrityMonitor:
35+
"""Periodically verify a manifest and fire alerts on drift."""
36+
37+
def __init__(
38+
self,
39+
root: str | Path,
40+
manifest_path: str | Path,
41+
*,
42+
interval: float = 60.0,
43+
on_drift: OnDrift | None = None,
44+
manager: NotificationManager | None = None,
45+
alert_on_extra: bool = False,
46+
) -> None:
47+
if interval <= 0:
48+
raise FileAutomationException("interval must be positive")
49+
self._root = Path(root)
50+
self._manifest_path = Path(manifest_path)
51+
self._interval = float(interval)
52+
self._on_drift = on_drift
53+
self._manager = manager or notification_manager
54+
self._alert_on_extra = bool(alert_on_extra)
55+
self._stop = threading.Event()
56+
self._thread: threading.Thread | None = None
57+
self._last_summary: dict[str, Any] | None = None
58+
59+
@property
60+
def last_summary(self) -> dict[str, Any] | None:
61+
return self._last_summary
62+
63+
def start(self) -> None:
64+
"""Arm the monitor. The first verification runs on the next tick."""
65+
if self._thread is not None and self._thread.is_alive():
66+
return
67+
self._stop.clear()
68+
thread = threading.Thread(target=self._run, name="fa-integrity-monitor", daemon=True)
69+
thread.start()
70+
self._thread = thread
71+
file_automation_logger.info(
72+
"integrity_monitor: watching %s against %s (interval=%.1fs)",
73+
self._root,
74+
self._manifest_path,
75+
self._interval,
76+
)
77+
78+
def stop(self, timeout: float = 5.0) -> None:
79+
self._stop.set()
80+
thread = self._thread
81+
self._thread = None
82+
if thread is not None and thread.is_alive():
83+
thread.join(timeout=timeout)
84+
85+
def check_once(self) -> dict[str, Any]:
86+
"""Run one verification pass and return the summary."""
87+
try:
88+
summary = verify_manifest(self._root, self._manifest_path)
89+
except (ManifestException, FileAutomationException) as err:
90+
file_automation_logger.error("integrity_monitor: verify failed: %r", err)
91+
summary = {
92+
"matched": [],
93+
"missing": [],
94+
"modified": [],
95+
"extra": [],
96+
"ok": False,
97+
"error": repr(err),
98+
}
99+
self._last_summary = summary
100+
if self._is_drift(summary):
101+
self._handle_drift(summary)
102+
return summary
103+
104+
def _is_drift(self, summary: dict[str, Any]) -> bool:
105+
if summary.get("error"):
106+
return True
107+
if summary.get("missing") or summary.get("modified"):
108+
return True
109+
return bool(self._alert_on_extra and summary.get("extra"))
110+
111+
def _handle_drift(self, summary: dict[str, Any]) -> None:
112+
file_automation_logger.warning(
113+
"integrity_monitor: drift detected missing=%d modified=%d extra=%d",
114+
len(summary.get("missing") or []),
115+
len(summary.get("modified") or []),
116+
len(summary.get("extra") or []),
117+
)
118+
if self._on_drift is not None:
119+
try:
120+
self._on_drift(summary)
121+
except FileAutomationException as err:
122+
file_automation_logger.error("integrity_monitor: on_drift raised: %r", err)
123+
body = _format_body(summary)
124+
try:
125+
self._manager.notify(
126+
subject=f"integrity drift: {self._root}",
127+
body=body,
128+
level="error",
129+
)
130+
except FileAutomationException as err:
131+
file_automation_logger.error("integrity_monitor: notify failed: %r", err)
132+
133+
def _run(self) -> None:
134+
while not self._stop.is_set():
135+
self._stop.wait(self._interval)
136+
if self._stop.is_set():
137+
break
138+
self.check_once()
139+
140+
141+
def _format_body(summary: dict[str, Any]) -> str:
142+
parts: list[str] = []
143+
if summary.get("error"):
144+
parts.append(f"error: {summary['error']}")
145+
for key in ("missing", "modified", "extra"):
146+
items = summary.get(key) or []
147+
if items:
148+
preview = ", ".join(items[:5])
149+
suffix = f" (+{len(items) - 5} more)" if len(items) > 5 else ""
150+
parts.append(f"{key}: {preview}{suffix}")
151+
return "\n".join(parts) if parts else "no drift detected"

tests/test_fim.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Tests for the file integrity monitor."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import pytest
9+
10+
from automation_file import IntegrityMonitor, write_manifest
11+
from automation_file.exceptions import FileAutomationException
12+
from automation_file.notify import NotificationManager
13+
from automation_file.notify.sinks import NotificationSink
14+
15+
16+
class _Recorder(NotificationSink):
17+
name = "recorder"
18+
19+
def __init__(self) -> None:
20+
self.messages: list[tuple[str, str, str]] = []
21+
22+
def send(self, subject: str, body: str, level: str = "info") -> None:
23+
self.messages.append((subject, body, level))
24+
25+
26+
def _build_tree(tmp_path: Path) -> tuple[Path, Path]:
27+
root = tmp_path / "tree"
28+
root.mkdir()
29+
(root / "a.txt").write_text("alpha", encoding="utf-8")
30+
(root / "b.txt").write_text("bravo", encoding="utf-8")
31+
manifest_path = tmp_path / "manifest.json"
32+
write_manifest(root, manifest_path)
33+
return root, manifest_path
34+
35+
36+
def test_check_once_clean_tree(tmp_path: Path) -> None:
37+
root, manifest_path = _build_tree(tmp_path)
38+
monitor = IntegrityMonitor(root, manifest_path)
39+
summary = monitor.check_once()
40+
assert summary["ok"] is True
41+
assert monitor.last_summary is summary
42+
43+
44+
def test_check_once_detects_modified_file_and_notifies(tmp_path: Path) -> None:
45+
root, manifest_path = _build_tree(tmp_path)
46+
(root / "a.txt").write_text("tampered", encoding="utf-8")
47+
manager = NotificationManager(dedup_seconds=0.0)
48+
recorder = _Recorder()
49+
manager.register(recorder)
50+
monitor = IntegrityMonitor(root, manifest_path, manager=manager)
51+
summary = monitor.check_once()
52+
assert summary["ok"] is False
53+
assert "a.txt" in summary["modified"]
54+
assert recorder.messages, "expected a drift notification"
55+
subject, body, level = recorder.messages[0]
56+
assert level == "error"
57+
assert "integrity drift" in subject
58+
assert "a.txt" in body
59+
60+
61+
def test_check_once_detects_missing_file(tmp_path: Path) -> None:
62+
root, manifest_path = _build_tree(tmp_path)
63+
(root / "b.txt").unlink()
64+
manager = NotificationManager(dedup_seconds=0.0)
65+
recorder = _Recorder()
66+
manager.register(recorder)
67+
monitor = IntegrityMonitor(root, manifest_path, manager=manager)
68+
summary = monitor.check_once()
69+
assert "b.txt" in summary["missing"]
70+
assert recorder.messages
71+
72+
73+
def test_extras_ignored_by_default(tmp_path: Path) -> None:
74+
root, manifest_path = _build_tree(tmp_path)
75+
(root / "new.txt").write_text("novel", encoding="utf-8")
76+
manager = NotificationManager(dedup_seconds=0.0)
77+
recorder = _Recorder()
78+
manager.register(recorder)
79+
monitor = IntegrityMonitor(root, manifest_path, manager=manager)
80+
summary = monitor.check_once()
81+
assert "new.txt" in summary["extra"]
82+
assert summary["ok"] is True
83+
assert recorder.messages == []
84+
85+
86+
def test_alert_on_extra_flag(tmp_path: Path) -> None:
87+
root, manifest_path = _build_tree(tmp_path)
88+
(root / "new.txt").write_text("novel", encoding="utf-8")
89+
manager = NotificationManager(dedup_seconds=0.0)
90+
recorder = _Recorder()
91+
manager.register(recorder)
92+
monitor = IntegrityMonitor(root, manifest_path, manager=manager, alert_on_extra=True)
93+
monitor.check_once()
94+
assert recorder.messages
95+
96+
97+
def test_on_drift_callback_invoked(tmp_path: Path) -> None:
98+
root, manifest_path = _build_tree(tmp_path)
99+
(root / "a.txt").write_text("tampered", encoding="utf-8")
100+
seen: list[dict[str, Any]] = []
101+
monitor = IntegrityMonitor(
102+
root,
103+
manifest_path,
104+
manager=NotificationManager(dedup_seconds=0.0),
105+
on_drift=seen.append,
106+
)
107+
monitor.check_once()
108+
assert len(seen) == 1
109+
assert "a.txt" in seen[0]["modified"]
110+
111+
112+
def test_missing_manifest_treated_as_drift(tmp_path: Path) -> None:
113+
root = tmp_path / "tree"
114+
root.mkdir()
115+
(root / "a.txt").write_text("alpha", encoding="utf-8")
116+
manager = NotificationManager(dedup_seconds=0.0)
117+
recorder = _Recorder()
118+
manager.register(recorder)
119+
monitor = IntegrityMonitor(root, tmp_path / "missing.json", manager=manager)
120+
summary = monitor.check_once()
121+
assert summary["ok"] is False
122+
assert "error" in summary
123+
assert recorder.messages
124+
125+
126+
def test_positive_interval_required(tmp_path: Path) -> None:
127+
root, manifest_path = _build_tree(tmp_path)
128+
with pytest.raises(FileAutomationException):
129+
IntegrityMonitor(root, manifest_path, interval=0)
130+
131+
132+
def test_start_and_stop_thread(tmp_path: Path) -> None:
133+
root, manifest_path = _build_tree(tmp_path)
134+
monitor = IntegrityMonitor(root, manifest_path, interval=0.05)
135+
monitor.start()
136+
try:
137+
# Second start is a no-op.
138+
monitor.start()
139+
finally:
140+
monitor.stop(timeout=1.0)

0 commit comments

Comments
 (0)