Files
DECNET/tests/ttp/store/test_filesystem.py
anti f41995a229 feat(ttp): E.3.5 FilesystemRuleStore — inotify hot-reload + per-rule events
Implements the filesystem-backed rule store body left empty at contract
phase: YAML parse + Pydantic validation, asyncinotify watch over
./rules/ttp/, in-process state cache with auto-revert on expires_at,
and a subscribe_changes() async iterator yielding one RuleChange per
per-rule edit. Bus topic builders ttp_rule_reloaded / ttp_rule_state
ship alongside.

Why: the rule plane needed a store before the engine (E.3.7) could
consume RuleChange events and atomically swap compiled rules into its
dispatch index.

Notes:
- Linux-only by construction (asyncinotify wheel gated by sys_platform
  marker; FilesystemRuleStore.__init__ raises on non-Linux).
- Filename allowlist is the FIRST check on every inotify event.
- Content-hash dedup so a single write firing IN_CREATE + IN_CLOSE_WRITE
  produces exactly one RuleChange.
- All compile work serializes on a single asyncio.Lock.
- Subscribers register their queue eagerly so events fired between
  subscribe_changes() and the first __anext__() are buffered.

xfails flipped: per-save-style + filter-ordering + atomic-swap in
test_filesystem.py; load_compiled / set_state isolation / round-trip /
per-rule fan-out / expired-state revert / set_state failure semantics
in test_conformance.py (FS side; DB side stays xfail until E.3.6);
malformed-YAML compile-time check in test_rule_engine.py.

Tests: 197 passed, 35 xfailed (gated on E.3.6 / E.3.7 / lifters).
mypy + bandit: clean on all touched files.

Wiki update for the per-rule reload + state-change topics lands in a
matching wiki-checkout/Service-Bus.md edit (separate repo).
2026-05-01 08:31:05 -04:00

309 lines
12 KiB
Python

"""E.2.14b — Filesystem-specific RuleStore properties.
Pins behavior that's unique to :class:`FilesystemRuleStore`:
* Inotify event mask covers four save styles (``IN_CLOSE_WRITE``,
``IN_MOVED_TO``, ``IN_CREATE``, ``IN_DELETE``) per ``strace`` of
vim and other editors. Verified by parametrizing each case and
asserting one event per save.
* Filename allowlist (vs denylist) — dotfile / scratch / wrong-ext
filenames produce zero events and zero loaded rules; the positive
sibling case still loads.
* CLOSE_WRITE on a filtered name produces NEITHER a parse attempt
NOR a log line. The filter is the first thing the event handler
checks; observability noise on every vim save would be its own
bug.
* Atomic-swap concurrency: parallel edits compile in a serialized
stream; concurrent :meth:`evaluate` sees only fully-frozen
``CompiledRule`` (NamedTuple, mutation-resistant).
Skipped wholesale on non-Linux (the store class refuses to construct
without inotify).
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
from pathlib import Path
import pytest
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleChange, RuleState
from decnet.ttp.store.impl import filesystem as fs
pytestmark = pytest.mark.skipif(
sys.platform != "linux",
reason="FilesystemRuleStore is Linux-only (inotify dep)",
)
_RULE_YAML = """\
rule_id: {rule_id}
rule_version: 1
name: test rule
applies_to: [command]
match:
pattern: 'hydra'
emits:
- technique_id: T1110
evidence_fields: [matched_tokens]
"""
def _write_rule(path: Path, rule_id: str = "R0001") -> None:
path.write_text(_RULE_YAML.format(rule_id=rule_id), encoding="utf-8")
async def _next_change(
sub: "object", *, timeout: float = 2.0,
) -> RuleChange:
return await asyncio.wait_for(sub.__anext__(), timeout=timeout) # type: ignore[attr-defined]
# ── Constants (GREEN today) ─────────────────────────────────────────
def test_inotify_mask_covers_four_save_styles() -> None:
"""The mask is the bitwise OR of IN_CLOSE_WRITE | IN_MOVED_TO |
IN_CREATE | IN_DELETE. Each bit covers one canonical save style.
Pinning the OR pins the contract: a future contributor cannot
quietly drop a bit (and silently miss an editor's save mode).
"""
expected = (
fs._IN_CLOSE_WRITE
| fs._IN_MOVED_TO
| fs._IN_CREATE
| fs._IN_DELETE
)
assert fs._INOTIFY_MASK == expected
# Each bit must actually be set in the composite.
for bit in (
fs._IN_CLOSE_WRITE,
fs._IN_MOVED_TO,
fs._IN_CREATE,
fs._IN_DELETE,
):
assert fs._INOTIFY_MASK & bit
def test_inotify_mask_uses_canonical_kernel_values() -> None:
"""Bit values match ``<sys/inotify.h>``. Sanity check against
accidental endianness / byte-shuffle bugs in the inlined
constants. Real values from man inotify(7).
"""
assert fs._IN_CLOSE_WRITE == 0x00000008
assert fs._IN_MOVED_TO == 0x00000080
assert fs._IN_CREATE == 0x00000100
assert fs._IN_DELETE == 0x00000200
# ── Filename allowlist (GREEN today) ────────────────────────────────
@pytest.mark.parametrize(
"filename",
[
".T1110_brute_force.yaml.swp", # vim swap
".T1110_brute_force.yaml.swo", # secondary vim swap
"T1110_brute_force.yaml~", # tilde backup
".T1110_brute_force.yaml.bak", # dot-prefix backup
"4913", # vim atomic-save probe
".4913", # dot-prefix variant
".foo", # any dotfile, no yaml
"T1110_brute_force.yaml.tmp", # wrong extension
"T1110_brute_force.txt", # right shape, wrong ext
],
)
def test_scratch_filenames_rejected_by_allowlist(filename: str) -> None:
"""Dotfile / scratch / wrong-extension filenames fail the
allowlist regex.
Listed exhaustively (rather than property-tested) because the
allowlist's *exclusion* set is the load-bearing surface — a
future "let's also accept .yaml.tmp" PR must trip this test
deliberately.
"""
assert fs._VALID_RULE_FILENAME.fullmatch(filename) is None
@pytest.mark.parametrize(
"filename",
[
"T1110_brute_force.yaml",
"T1078.yaml",
"T1059_command_and_scripting.yml",
"R0001.yaml",
],
)
def test_valid_rule_filenames_accepted(filename: str) -> None:
"""Positive cases: real rule files are accepted by the allowlist
regex. Confirms the filter excludes scratch files without
false-rejecting real ones."""
assert fs._VALID_RULE_FILENAME.fullmatch(filename) is not None
def test_filename_allowlist_uses_fullmatch_semantics() -> None:
"""The pattern uses ``\\Z`` / ``fullmatch`` — anchoring is
load-bearing. ``foo.yaml.tmp`` would match a non-anchored
``.yaml`` substring search but is correctly rejected by
``fullmatch``. Pinning the regex's anchor behavior catches a
refactor to ``re.search`` that would silently widen the
allowlist."""
# The compiled pattern as authored doesn't carry trailing $
# because fullmatch implicitly anchors. The string we test
# passes a no-anchor `search` but fails `fullmatch`.
assert re.compile(fs._VALID_RULE_FILENAME.pattern).search(
"T1110_brute_force.yaml.tmp",
) is not None
assert fs._VALID_RULE_FILENAME.fullmatch(
"T1110_brute_force.yaml.tmp",
) is None
# ── Construction guard (GREEN today) ────────────────────────────────
def test_construct_with_tmp_path_works(tmp_path) -> None:
"""The constructor accepts an explicit ``rules_dir`` so tests
can sandbox without touching the real ``./rules/``."""
store = fs.FilesystemRuleStore(rules_dir=tmp_path)
assert store._rules_dir == tmp_path
# ── CompiledRule immutability (GREEN today) ─────────────────────────
def test_compiled_rule_is_frozen() -> None:
"""``CompiledRule`` is a :class:`NamedTuple`, so field
assignment raises ``AttributeError``. The doc references
``FrozenInstanceError`` (the dataclass equivalent), but the
actual implementation uses NamedTuple — the in-test smoke
signal is the same property (mutation-resistant) under a
different exception type. Pinning the AttributeError behavior
here clarifies the contract for future readers."""
rule = CompiledRule(
rule_id="R0001",
rule_version=1,
name="test",
applies_to=frozenset({"attacker_command"}),
match_spec={},
emits=(("T1110", None),),
evidence_fields=(),
state=RuleState(),
)
with pytest.raises(AttributeError):
rule.rule_id = "tampered" # type: ignore[misc] # deliberate mutation attempt
# ── Inotify save-style coverage (E.3.5 — flipped) ───────────────────
@pytest.mark.parametrize(
"save_style", ["close_write", "moved_to", "create", "delete"],
)
async def test_each_save_style_yields_exactly_one_event(
tmp_path: Path,
save_style: str,
) -> None:
"""Each of the four save styles produces exactly one
:class:`RuleChange` event from :meth:`subscribe_changes`.
Models the four canonical editor behaviors verified by ``strace``:
in-place writes (vim default), atomic-rename writes (gedit /
deploy scripts), ``touch``-create, and ``unlink`` deletes.
"""
rule_path = tmp_path / "R0001.yaml"
if save_style in ("close_write", "moved_to", "delete"):
_write_rule(rule_path)
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
sub = store.subscribe_changes()
await asyncio.sleep(0.05) # let watcher settle on the dir
if save_style == "close_write":
rule_path.write_text(
_RULE_YAML.format(rule_id="R0001").replace(
"rule_version: 1", "rule_version: 2",
),
encoding="utf-8",
)
elif save_style == "moved_to":
tmp = tmp_path / "R0001.yaml.swap"
tmp.write_text(
_RULE_YAML.format(rule_id="R0001").replace(
"rule_version: 1", "rule_version: 3",
),
encoding="utf-8",
)
os.rename(tmp, rule_path)
elif save_style == "create":
(tmp_path / "R0002.yaml").write_text(
_RULE_YAML.format(rule_id="R0002"), encoding="utf-8",
)
elif save_style == "delete":
os.unlink(rule_path)
change = await _next_change(sub)
assert change.change_kind == "definition"
if save_style == "delete":
assert change.rule_id == "R0001"
elif save_style == "create":
assert change.rule_id == "R0002"
else:
assert change.rule_id == "R0001"
async def test_close_write_on_filtered_name_emits_no_log_line(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A CLOSE_WRITE event on a name failing the allowlist (e.g.
``.foo.yaml.swp``) produces NEITHER a parse attempt NOR a log
line. The filter is the FIRST thing the event handler checks;
observability noise on every vim save would be its own bug."""
caplog.set_level("DEBUG", logger="ttp.store.filesystem")
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
sub = store.subscribe_changes()
await asyncio.sleep(0.05)
# Vim swap file: must be silently ignored.
(tmp_path / ".R0001.yaml.swp").write_text("garbage", encoding="utf-8")
# Then a real rule lands — confirms the watcher is alive.
_write_rule(tmp_path / "R0001.yaml")
change = await _next_change(sub)
assert change.rule_id == "R0001"
# No log line about the swap file (parse, error, anything).
assert all(
".swp" not in record.message for record in caplog.records
), "scratch-file filter should not log filtered names"
# ── Atomic-swap concurrency (E.3.5 — flipped) ───────────────────────
async def test_atomic_swap_serializes_compile(tmp_path: Path) -> None:
"""N parallel writers editing distinct rule files compile in a
single ordered stream. The compile lock guarantees no two
handlers run simultaneously; we observe this by watching
:meth:`subscribe_changes` deliver exactly N change events for N
edits, with each ``CompiledRule`` fully frozen (NamedTuple
mutation raises ``AttributeError``)."""
n = 5
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
sub = store.subscribe_changes()
await asyncio.sleep(0.05)
# Storm of independent edits.
for i in range(n):
(tmp_path / f"R000{i}.yaml").write_text(
_RULE_YAML.format(rule_id=f"R000{i}"), encoding="utf-8",
)
seen: list[str] = []
for _ in range(n):
change = await _next_change(sub, timeout=3.0)
assert change.change_kind == "definition"
new_value = change.new_value
assert isinstance(new_value, CompiledRule)
with pytest.raises(AttributeError):
new_value.rule_id = "tampered" # type: ignore[misc]
seen.append(change.rule_id)
assert sorted(seen) == [f"R000{i}" for i in range(n)]