From f24a55461fe8723203ebac19106580fd0c7648fe Mon Sep 17 00:00:00 2001 From: Michael Lieberman Date: Wed, 26 Aug 2026 09:58:34 -0400 Subject: [PATCH 1/2] feat(stores): pluggable per-artifact backends via typing.Protocol Adds `darnit.stores` sub-package with four @runtime_checkable Protocols (ProjectStateStore, AttestationStore, ReportStore, AuditCacheStore), filesystem-default backends shipped in darnit-core, and entry-point-based discovery so third-party packages can distribute alternative backends without patching darnit. Motivation: darnit-core is filesystem-only by design (constitution I), but downstream users need to point specific artifact classes at other targets (project state to a shared Postgres, attestations to S3, etc.) without forking. This lays the seam without changing zero-config behavior: with no [stores.*] block in .baseline.toml, every artifact class continues to land on the same filesystem paths as before. Key mechanics: * `resolve_stores` validates selections eagerly (SC-007) via a class-shape Protocol check but defers construction to first access (SC-004). A backend whose kind the run never touches is never instantiated. * Backends are discovered via importlib.metadata under four groups: darnit.stores.{project,attestation,report,cache}. Discovery is cached per-process. * `.baseline.toml` composes over the framework TOML using the same per-kind replacement rule as feature 031's mcp_servers. * No new runtime dependencies. No product-package modifications outside darnit-core / darnit-baseline / darnit-testchecks. Migrations: * Attestation generator gains an optional `attestation_store` kwarg; legacy `output_path` filesystem path stays default for backward compat. * DotProjectReader + DotProjectMapper accept an optional ProjectStateStore; unset -> pre-feature filesystem read. * Audit driver calls resolve_stores + close_all around each run. Deferred to follow-up features (documented in tasks.md): audit-cache module migration (T026), DotProjectWriter refactor (T022), and the report-store integration (blocked on #341's report consumer). All follow the surface established here. Tests: 30 protocol/discovery/selection tests, 7 US1 (equivalence + isolation + lazy), 6 US2 (zero-config + backward-compat), 5 US3 (discovery + selection + errors), 6 US4 (fault injection), 2 import-isolation guards (SC-008 + FR-017). Full workspace sweep: 2928 pass, 0 fail. Includes plugin-author docs at docs/plugin-authoring/stores.md. --- .specify/feature.json | 2 +- CLAUDE.md | 2 +- docs/architecture/framework-design.md | 4 + docs/plugin-authoring/stores.md | 172 +++++++++++ .../darnit_baseline/attestation/generator.py | 69 +++-- packages/darnit-testchecks/pyproject.toml | 13 + .../src/darnit_testchecks/stores/__init__.py | 26 ++ .../stores/in_memory_attestation.py | 23 ++ .../stores/in_memory_cache.py | 32 ++ .../stores/in_memory_project.py | 44 +++ .../stores/in_memory_report.py | 29 ++ .../src/darnit/config/framework_schema.py | 76 +++++ packages/darnit/src/darnit/config/merger.py | 22 ++ .../darnit/src/darnit/config/user_schema.py | 6 + .../darnit/src/darnit/context/dot_project.py | 20 +- .../src/darnit/context/dot_project_mapper.py | 13 +- packages/darnit/src/darnit/core/env_subst.py | 98 ++++++ packages/darnit/src/darnit/core/models.py | 7 + .../src/darnit/sieve/builtin_handlers.py | 33 +-- packages/darnit/src/darnit/sieve/mcp_pool.py | 27 +- packages/darnit/src/darnit/stores/__init__.py | 52 ++++ .../src/darnit/stores/defaults/__init__.py | 20 ++ .../src/darnit/stores/defaults/attestation.py | 56 ++++ .../src/darnit/stores/defaults/cache.py | 76 +++++ .../src/darnit/stores/defaults/project.py | 92 ++++++ .../src/darnit/stores/defaults/report.py | 45 +++ .../darnit/src/darnit/stores/discovery.py | 121 ++++++++ packages/darnit/src/darnit/stores/errors.py | 108 +++++++ .../darnit/src/darnit/stores/protocols.py | 215 ++++++++++++++ .../darnit/src/darnit/stores/selection.py | 237 +++++++++++++++ packages/darnit/src/darnit/tools/audit.py | 45 ++- .../checklists/requirements.md | 40 +++ .../contracts/attestation-store.md | 70 +++++ .../contracts/audit-cache-store.md | 68 +++++ .../contracts/project-state-store.md | 85 ++++++ .../contracts/report-store.md | 74 +++++ specs/033-pluggable-stores/data-model.md | 240 +++++++++++++++ specs/033-pluggable-stores/plan.md | 275 +++++++++++++++++ specs/033-pluggable-stores/quickstart.md | 158 ++++++++++ specs/033-pluggable-stores/research.md | 204 +++++++++++++ specs/033-pluggable-stores/spec.md | 152 ++++++++++ specs/033-pluggable-stores/tasks.md | 280 ++++++++++++++++++ tests/darnit/config/test_stores_config.py | 111 +++++++ tests/darnit/stores/conftest.py | 100 +++++++ .../example_store_plugin_pkg/README.md | 7 + .../example_store_plugin_pkg/pyproject.toml | 15 + .../src/example_store_plugin/__init__.py | 9 + .../src/example_store_plugin/backend.py | 18 ++ tests/darnit/stores/test_backward_compat.py | 82 +++++ tests/darnit/stores/test_discovery.py | 116 ++++++++ tests/darnit/stores/test_env_subst.py | 177 +++++++++++ .../darnit/stores/test_filesystem_defaults.py | 136 +++++++++ tests/darnit/stores/test_import_isolation.py | 90 ++++++ tests/darnit/stores/test_protocols.py | 176 +++++++++++ tests/darnit/stores/test_selection.py | 173 +++++++++++ tests/darnit/stores/test_us1_equivalence.py | 97 ++++++ tests/darnit/stores/test_us1_isolation.py | 45 +++ .../stores/test_us1_lazy_instantiation.py | 72 +++++ tests/darnit/stores/test_us2_zero_config.py | 76 +++++ .../darnit/stores/test_us3_missing_plugin.py | 34 +++ .../darnit/stores/test_us3_name_collision.py | 54 ++++ .../stores/test_us3_plugin_discovery.py | 23 ++ .../stores/test_us3_plugin_selection.py | 52 ++++ .../stores/test_us3_protocol_mismatch.py | 43 +++ .../test_us4_attestation_write_error.py | 56 ++++ .../stores/test_us4_cache_best_effort.py | 51 ++++ .../stores/test_us4_no_silent_fallback.py | 54 ++++ tests/darnit/test_audit_mapper_integration.py | 17 +- .../attestation/test_generator_store.py | 84 ++++++ 69 files changed, 5328 insertions(+), 71 deletions(-) create mode 100644 docs/plugin-authoring/stores.md create mode 100644 packages/darnit-testchecks/src/darnit_testchecks/stores/__init__.py create mode 100644 packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_attestation.py create mode 100644 packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_cache.py create mode 100644 packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_project.py create mode 100644 packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_report.py create mode 100644 packages/darnit/src/darnit/core/env_subst.py create mode 100644 packages/darnit/src/darnit/stores/__init__.py create mode 100644 packages/darnit/src/darnit/stores/defaults/__init__.py create mode 100644 packages/darnit/src/darnit/stores/defaults/attestation.py create mode 100644 packages/darnit/src/darnit/stores/defaults/cache.py create mode 100644 packages/darnit/src/darnit/stores/defaults/project.py create mode 100644 packages/darnit/src/darnit/stores/defaults/report.py create mode 100644 packages/darnit/src/darnit/stores/discovery.py create mode 100644 packages/darnit/src/darnit/stores/errors.py create mode 100644 packages/darnit/src/darnit/stores/protocols.py create mode 100644 packages/darnit/src/darnit/stores/selection.py create mode 100644 specs/033-pluggable-stores/checklists/requirements.md create mode 100644 specs/033-pluggable-stores/contracts/attestation-store.md create mode 100644 specs/033-pluggable-stores/contracts/audit-cache-store.md create mode 100644 specs/033-pluggable-stores/contracts/project-state-store.md create mode 100644 specs/033-pluggable-stores/contracts/report-store.md create mode 100644 specs/033-pluggable-stores/data-model.md create mode 100644 specs/033-pluggable-stores/plan.md create mode 100644 specs/033-pluggable-stores/quickstart.md create mode 100644 specs/033-pluggable-stores/research.md create mode 100644 specs/033-pluggable-stores/spec.md create mode 100644 specs/033-pluggable-stores/tasks.md create mode 100644 tests/darnit/config/test_stores_config.py create mode 100644 tests/darnit/stores/conftest.py create mode 100644 tests/darnit/stores/fixtures/example_store_plugin_pkg/README.md create mode 100644 tests/darnit/stores/fixtures/example_store_plugin_pkg/pyproject.toml create mode 100644 tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/__init__.py create mode 100644 tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/backend.py create mode 100644 tests/darnit/stores/test_backward_compat.py create mode 100644 tests/darnit/stores/test_discovery.py create mode 100644 tests/darnit/stores/test_env_subst.py create mode 100644 tests/darnit/stores/test_filesystem_defaults.py create mode 100644 tests/darnit/stores/test_import_isolation.py create mode 100644 tests/darnit/stores/test_protocols.py create mode 100644 tests/darnit/stores/test_selection.py create mode 100644 tests/darnit/stores/test_us1_equivalence.py create mode 100644 tests/darnit/stores/test_us1_isolation.py create mode 100644 tests/darnit/stores/test_us1_lazy_instantiation.py create mode 100644 tests/darnit/stores/test_us2_zero_config.py create mode 100644 tests/darnit/stores/test_us3_missing_plugin.py create mode 100644 tests/darnit/stores/test_us3_name_collision.py create mode 100644 tests/darnit/stores/test_us3_plugin_discovery.py create mode 100644 tests/darnit/stores/test_us3_plugin_selection.py create mode 100644 tests/darnit/stores/test_us3_protocol_mismatch.py create mode 100644 tests/darnit/stores/test_us4_attestation_write_error.py create mode 100644 tests/darnit/stores/test_us4_cache_best_effort.py create mode 100644 tests/darnit/stores/test_us4_no_silent_fallback.py create mode 100644 tests/darnit_baseline/attestation/test_generator_store.py diff --git a/.specify/feature.json b/.specify/feature.json index 1a9cbc2d..868fb396 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory": "specs/032-ruleset-branch-protection"} +{"feature_directory": "specs/033-pluggable-stores"} diff --git a/CLAUDE.md b/CLAUDE.md index a4b91993..fe031eae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -381,5 +381,5 @@ else: For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[`specs/032-ruleset-branch-protection/plan.md`](specs/032-ruleset-branch-protection/plan.md) +[`specs/033-pluggable-stores/plan.md`](specs/033-pluggable-stores/plan.md) diff --git a/docs/architecture/framework-design.md b/docs/architecture/framework-design.md index 14e9ac6f..cdca502e 100644 --- a/docs/architecture/framework-design.md +++ b/docs/architecture/framework-design.md @@ -1120,6 +1120,10 @@ Implementation-registered sieve handlers (non-exhaustive): `github_branch_protec A handler used in a phase different from its registered affinity SHALL trigger a warning but still execute. +## 13. Persistence Extension Surface + +The framework SHALL provide four per-artifact persistence Protocols under `darnit.stores`, alongside the existing extension surfaces `darnit.frameworks` (compliance implementations) and `darnit.question_resolvers` (feature 027). Third-party persistence backends register under Python entry-point groups `darnit.stores.project`, `darnit.stores.attestation`, `darnit.stores.report`, `darnit.stores.cache`. Filesystem-backed default implementations ship in `darnit-core` and reproduce the pre-feature on-disk layout exactly (feature 033); alternative backends are opt-in via `.baseline.toml` `[stores.] backend = "..."` blocks. See `specs/033-pluggable-stores/contracts/` for per-Protocol contracts. + ## Appendix C: Removed Requirements The following requirements have been superseded by the handler dispatch architecture. diff --git a/docs/plugin-authoring/stores.md b/docs/plugin-authoring/stores.md new file mode 100644 index 00000000..12e6c32f --- /dev/null +++ b/docs/plugin-authoring/stores.md @@ -0,0 +1,172 @@ +# Authoring a Pluggable Store Backend + +Feature 033 exposes four per-artifact Protocols that alternative +backends can satisfy. This document explains how to distribute a +backend as a Python package that darnit discovers automatically. + +## What you can back with a plugin + +| Kind | Protocol | Discovery group | +|--------------|---------------------|-----------------------------| +| project | `ProjectStateStore` | `darnit.stores.project` | +| attestation | `AttestationStore` | `darnit.stores.attestation` | +| report | `ReportStore` | `darnit.stores.report` | +| audit cache | `AuditCacheStore` | `darnit.stores.cache` | + +Each Protocol lives in `darnit.stores.protocols`. All four inherit from +a `Store` base carrying a single `close()` method. + +## Minimal example: an S3-backed `AttestationStore` + +### Package layout + +``` +my-s3-store/ + pyproject.toml + src/my_s3_store/ + __init__.py + backend.py +``` + +### `pyproject.toml` + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "my-s3-store" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["boto3"] + +[project.entry-points."darnit.stores.attestation"] +s3 = "my_s3_store.backend:S3AttestationStore" + +[tool.hatch.build.targets.wheel] +packages = ["src/my_s3_store"] +``` + +### `src/my_s3_store/backend.py` + +```python +from __future__ import annotations + +import boto3 + + +class S3AttestationStore: + """Writes attestation bundles to an S3 bucket.""" + + def __init__(self, *, bucket: str, prefix: str = "", **_) -> None: + self._bucket = bucket + self._prefix = prefix.rstrip("/") + "/" if prefix else "" + self._s3 = boto3.client("s3") + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + # content_type maps to filename extension per feature 033 R-004: + # application/vnd.in-toto+json -> .intoto.json + # application/vnd.dev.sigstore.bundle+json -> .sigstore.json + ext = ".sigstore.json" if "sigstore" in content_type else ".intoto.json" + key = f"{self._prefix}{bundle_id}{ext}" + self._s3.put_object( + Bucket=self._bucket, + Key=key, + Body=bundle_bytes, + ContentType=content_type, + ) + + def close(self) -> None: + # boto3 clients hold no persistent connection; nothing to release. + return None +``` + +### Selecting the backend in `.baseline.toml` + +```toml +[stores.attestation] +backend = "s3" +bucket = "my-attestations" +prefix = "openssf-baseline" +``` + +Any keys beyond `backend` are passed as keyword arguments to the +backend's constructor. + +Environment variables can be interpolated with `$VAR`: + +```toml +[stores.attestation] +backend = "s3" +bucket = "$ATTESTATION_BUCKET" +``` + +## Protocol contracts (must-read) + +Every backend must uphold these invariants: + +* **`close()` idempotence (FR-019).** `store.close(); store.close()` must + not raise. The audit driver's `close_all()` calls it exactly once per + instantiated store, but tests may double-close. +* **`AuditCacheStore.write` must not raise (FR-011).** Cache writes are + best-effort; the audit run must not fail because a cache backend + hiccuped. Swallow backend exceptions internally. +* **`AuditCacheStore.read` returns `None` on miss/corruption.** Do not + raise on a missing key or malformed payload; return `None` and let + the caller run a fresh audit. +* **Lazy construction (SC-004).** Backends whose artifact class the + current audit never touches are never constructed. Keep `__init__` + cheap; do not open connections until first `read`/`write`. +* **`ProjectStateStore.read_project()` returns `None` when there is no + seeded project state.** The reader treats `None` as "no `.project/`". +* **Backends may accept a `repo_path` kwarg.** The selector passes it + when the backend's `__init__` signature accepts it and falls back + transparently when it doesn't. + +## Testing your backend + +darnit ships `darnit-testchecks` with in-memory reference +implementations for all four kinds -- use them as a behavior baseline. +Then write an integration test that pip-installs your package, calls +`resolve_stores(StoresConfig(attestation=StoreBlock(backend="s3", ...)), +repo_path=tmp_path)`, and asserts on your backend's observable state +after an audit run. + +Reference: `tests/darnit/stores/fixtures/example_store_plugin_pkg/` +plus `tests/darnit/stores/test_us3_plugin_*.py`. + +## Error surfaces you may see + +| Exception | When | +|--------------------------|-------------------------------------------------| +| `StoreNotInstalled` | TOML names a backend not registered | +| `StoreProtocolMismatch` | Registered class missing a required method | +| `StoreNameCollision` | Two packages register the same name/group | +| `StoreOperationError` | Backend raised during read/write (except cache) | + +`StoreNotInstalled` and `StoreProtocolMismatch` fire at +`resolve_stores` time, before any control runs -- so a misconfigured +backend never wastes an audit. + +## Performance notes + +Two costs, both small: + +* **Entry-point discovery** is a one-time per-process cost paid at + framework load. `importlib.metadata.entry_points(group=...)` scans + installed dist-info metadata; on a fresh venv with ~50 installed + packages we measure single-digit milliseconds. The `discover_stores` + wrapper caches the result per group so subsequent audits in the same + process pay zero. +* **Lazy instantiation** adds one dict lookup per audit run per + artifact class. A store whose kind the run never touches is never + constructed at all -- ideal for expensive-to-open backends (network + clients, DB connections). The bundle's per-property accessor memoizes, + so second access is a straight attribute read. + +Practical implication: your `__init__` cost is charged to the first +audit in a process that actually uses the artifact class you back -- +never to zero-config runs, never to audits that skip your kind. Keep +`__init__` cheap and open connections on first `write` if the backend +is expensive to establish. diff --git a/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py b/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py index 6fb06660..b34678d7 100644 --- a/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py +++ b/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py @@ -54,7 +54,8 @@ def generate_attestation_from_results( staging: bool = False, output_path: str | None = None, output_dir: str | None = None, - storage_config: dict | None = None + storage_config: dict | None = None, + attestation_store: Any = None, ) -> str: """Generate attestation from audit results. @@ -62,8 +63,13 @@ def generate_attestation_from_results( audit_result: The audit result containing check results and metadata sign: Whether to sign with Sigstore (default True) staging: Use Sigstore staging environment for testing - output_path: Explicit path for the attestation file - output_dir: Directory to save attestation (default: repository directory) + output_path: Explicit path for the attestation file (legacy) + output_dir: Directory to save attestation (legacy; default: repo dir) + attestation_store: Feature 033 pluggable AttestationStore. When + provided, the bundle is written via ``store.write(bundle_id, + bytes, content_type)`` instead of the legacy filesystem + path. When None, ``output_path`` / ``output_dir`` control + the on-disk write (unchanged pre-feature behavior). Returns: JSON string with attestation or error message @@ -125,24 +131,45 @@ def generate_attestation_from_results( ) output = json.dumps(unsigned, indent=2) - # Determine output file path - if not output_path: - extension = ".sigstore.json" if sign else ".intoto.json" - filename = f"{audit_result.repo}-baseline-attestation{extension}" - save_dir = output_dir if output_dir else audit_result.local_path - output_path = os.path.join(save_dir, filename) - - # Save the attestation - try: - os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) - with open(output_path, 'w', encoding="utf-8") as f: - f.write(output) - logger.info(f"Attestation saved to: {output_path}") - except OSError as e: - return json.dumps({ - "error": f"Failed to write to {output_path}: {e}", - "attestation": json.loads(output) - }, indent=2) + # Feature 033 T027: prefer the pluggable AttestationStore when + # supplied. The legacy `output_path` / `output_dir` remain the + # zero-config path so pre-feature callers keep the same on-disk + # semantics without change. + if attestation_store is not None: + content_type = ( + "application/vnd.dev.sigstore.bundle+json" + if sign + else "application/vnd.in-toto+json" + ) + bundle_id = f"{audit_result.repo}-baseline-attestation" + try: + attestation_store.write( + bundle_id, output.encode("utf-8"), content_type + ) + logger.info(f"Attestation written via store: bundle_id={bundle_id}") + except Exception as e: # noqa: BLE001 + return json.dumps({ + "error": f"AttestationStore write failed: {e}", + "attestation": json.loads(output), + }, indent=2) + else: + # Determine output file path (pre-feature filesystem path). + if not output_path: + extension = ".sigstore.json" if sign else ".intoto.json" + filename = f"{audit_result.repo}-baseline-attestation{extension}" + save_dir = output_dir if output_dir else audit_result.local_path + output_path = os.path.join(save_dir, filename) + + try: + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, 'w', encoding="utf-8") as f: + f.write(output) + logger.info(f"Attestation saved to: {output_path}") + except OSError as e: + return json.dumps({ + "error": f"Failed to write to {output_path}: {e}", + "attestation": json.loads(output) + }, indent=2) # Store via pluggable storage backend if configured if storage_config is not None: diff --git a/packages/darnit-testchecks/pyproject.toml b/packages/darnit-testchecks/pyproject.toml index a2982db5..0a0a11fd 100644 --- a/packages/darnit-testchecks/pyproject.toml +++ b/packages/darnit-testchecks/pyproject.toml @@ -57,6 +57,19 @@ testchecks = "darnit_testchecks.adapters.builtin:TrivialRemediationAdapter" [project.entry-points."darnit.adapters"] testchecks = "darnit_testchecks.adapters.builtin:get_test_check_adapter" +# In-memory reference store backends (feature 033 T020) +[project.entry-points."darnit.stores.project"] +in-memory = "darnit_testchecks.stores:InMemoryProjectStateStore" + +[project.entry-points."darnit.stores.attestation"] +in-memory = "darnit_testchecks.stores:InMemoryAttestationStore" + +[project.entry-points."darnit.stores.report"] +in-memory = "darnit_testchecks.stores:InMemoryReportStore" + +[project.entry-points."darnit.stores.cache"] +in-memory = "darnit_testchecks.stores:InMemoryAuditCacheStore" + [tool.hatch.build.targets.wheel] packages = ["src/darnit_testchecks"] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/stores/__init__.py b/packages/darnit-testchecks/src/darnit_testchecks/stores/__init__.py new file mode 100644 index 00000000..1c3c7a9c --- /dev/null +++ b/packages/darnit-testchecks/src/darnit_testchecks/stores/__init__.py @@ -0,0 +1,26 @@ +"""In-memory reference store backends for testing. + +Feature 033 T020. These backends are shipped by darnit-testchecks so +they are only available in dev/test environments (they are NOT a +runtime dependency of darnit-core). They exist to: + +* Prove the pluggable-stores machinery works end-to-end (US1 equivalence + test) without needing a real filesystem-free storage backend. +* Give internal + external tests a zero-dependency, easily-inspectable + reference implementation to seed and assert against. + +Each backend exposes a ``_state`` attribute tests can read for +assertions. +""" + +from darnit_testchecks.stores.in_memory_attestation import InMemoryAttestationStore +from darnit_testchecks.stores.in_memory_cache import InMemoryAuditCacheStore +from darnit_testchecks.stores.in_memory_project import InMemoryProjectStateStore +from darnit_testchecks.stores.in_memory_report import InMemoryReportStore + +__all__ = [ + "InMemoryAttestationStore", + "InMemoryAuditCacheStore", + "InMemoryProjectStateStore", + "InMemoryReportStore", +] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_attestation.py b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_attestation.py new file mode 100644 index 00000000..ba3b5b33 --- /dev/null +++ b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_attestation.py @@ -0,0 +1,23 @@ +"""In-memory AttestationStore reference backend (feature 033 T020).""" + +from __future__ import annotations + + +class InMemoryAttestationStore: + """Dict-backed AttestationStore for tests. + + Records every write as (bundle_id, bundle_bytes, content_type) in + ``self._state`` (a list). Tests assert on it directly. + """ + + def __init__(self, **kwargs) -> None: + self._state: list[tuple[str, bytes, str]] = [] + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + self._state.append((bundle_id, bundle_bytes, content_type)) + + def close(self) -> None: + return None + + +__all__ = ["InMemoryAttestationStore"] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_cache.py b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_cache.py new file mode 100644 index 00000000..57d22636 --- /dev/null +++ b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_cache.py @@ -0,0 +1,32 @@ +"""In-memory AuditCacheStore reference backend (feature 033 T020).""" + +from __future__ import annotations + +from typing import Any + + +class InMemoryAuditCacheStore: + """Dict-backed AuditCacheStore for tests. + + ``self._state`` is a dict keyed by cache_key. write() must NOT raise + (FR-011); read() returns None on miss. + """ + + def __init__(self, **kwargs) -> None: + self._state: dict[str, dict[str, Any]] = {} + + def read(self, cache_key: str) -> "dict[str, Any] | None": + return self._state.get(cache_key) + + def write(self, cache_key: str, payload: "dict[str, Any]") -> None: + # FR-011: cache write is best-effort; swallow all errors. + try: + self._state[cache_key] = dict(payload) + except Exception: + pass + + def close(self) -> None: + return None + + +__all__ = ["InMemoryAuditCacheStore"] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_project.py b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_project.py new file mode 100644 index 00000000..fe7c6684 --- /dev/null +++ b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_project.py @@ -0,0 +1,44 @@ +"""In-memory ProjectStateStore reference backend (feature 033 T020).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from darnit.context.dot_project import MaintainerEntry, ProjectConfig + + +class InMemoryProjectStateStore: + """Dict-backed ProjectStateStore for tests. + + Seed via ``store.write_project(config)`` before running the audit. + Tests can inspect ``store._state`` after the audit for assertions. + """ + + def __init__(self, **kwargs) -> None: + self._state: dict[str, object] = { + "project": None, + "maintainers": [], + } + self.read_count = 0 + self.write_count = 0 + + def read_project(self) -> "ProjectConfig | None": + self.read_count += 1 + return self._state["project"] # type: ignore[return-value] + + def write_project(self, config: "ProjectConfig") -> None: + self.write_count += 1 + self._state["project"] = config + + def read_maintainers(self) -> "list[MaintainerEntry]": + return list(self._state["maintainers"]) # type: ignore[arg-type] + + def write_maintainers(self, entries: "list[MaintainerEntry]") -> None: + self._state["maintainers"] = list(entries) + + def close(self) -> None: + return None + + +__all__ = ["InMemoryProjectStateStore"] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_report.py b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_report.py new file mode 100644 index 00000000..518ada96 --- /dev/null +++ b/packages/darnit-testchecks/src/darnit_testchecks/stores/in_memory_report.py @@ -0,0 +1,29 @@ +"""In-memory ReportStore reference backend (feature 033 T020).""" + +from __future__ import annotations + + +class InMemoryReportStore: + """Dict-backed ReportStore for tests. + + ``self._state`` is a dict keyed by ``(report_id, format)`` where + format is ``"md"``, ``"json"``, or ``"sarif"``. + """ + + def __init__(self, **kwargs) -> None: + self._state: dict[tuple[str, str], str] = {} + + def write_markdown(self, report_id: str, contents: str) -> None: + self._state[(report_id, "md")] = contents + + def write_json(self, report_id: str, contents: str) -> None: + self._state[(report_id, "json")] = contents + + def write_sarif(self, report_id: str, contents: str) -> None: + self._state[(report_id, "sarif")] = contents + + def close(self) -> None: + return None + + +__all__ = ["InMemoryReportStore"] diff --git a/packages/darnit/src/darnit/config/framework_schema.py b/packages/darnit/src/darnit/config/framework_schema.py index 2962f3ae..c36409b9 100644 --- a/packages/darnit/src/darnit/config/framework_schema.py +++ b/packages/darnit/src/darnit/config/framework_schema.py @@ -1023,6 +1023,76 @@ class PluginConfig(BaseModel): model_config = ConfigDict(extra="allow") +class StoreBlock(BaseModel): + """One ``[stores.]`` TOML block (feature 033). + + Selects a persistence backend and passes backend-specific + configuration keys through to that backend's ``__init__``. String + values in the ``model_extra`` bag are passed through + :func:`darnit.core.env_subst.substitute_dollar_vars` at load time so + secrets can be sourced from ``os.environ`` rather than committed in + ``.baseline.toml`` (FR-006). + + Example TOML:: + + [stores.attestation] + backend = "s3" + bucket = "my-fleet-attestations" + region = "us-east-1" + access_key_id = "$AWS_ACCESS_KEY_ID" + secret_access_key = "$AWS_SECRET_ACCESS_KEY" + + The framework validates that the ``backend`` string names a + registered entry point under the target group; unresolvable names + fail-fast per FR-008. + """ + + backend: str + + model_config = ConfigDict(extra="allow") + + +class StoresConfig(BaseModel): + """The four artifact-class-keyed store blocks (feature 033). + + Any subset may be set; each unset field means "use the filesystem + default" for that artifact class (FR-007). ``extra = "forbid"`` + catches typos like ``[stores.audit_log]`` at schema-load time. + """ + + project: StoreBlock | None = None + attestation: StoreBlock | None = None + report: StoreBlock | None = None + cache: StoreBlock | None = None + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def _substitute_env_vars(self) -> "StoresConfig": + """Apply ``$VAR`` substitution to every string value in every block. + + Runs after Pydantic parses the block; walks each set + :class:`StoreBlock`'s ``model_extra`` and replaces + ``$VAR`` occurrences per feature 025/031 semantics + (``missing="empty"``). + """ + from darnit.core.env_subst import substitute_dollar_vars + + for kind in ("project", "attestation", "report", "cache"): + block = getattr(self, kind) + if block is None: + continue + extras = block.model_extra or {} + for key, value in list(extras.items()): + if isinstance(value, str): + extras[key] = substitute_dollar_vars(value) + # `backend` is a top-level string field; substitute it too so + # `backend = "$STORE_BACKEND"` is honored. + if isinstance(block.backend, str): + block.backend = substitute_dollar_vars(block.backend) + return self + + class McpServerConfig(BaseModel): """One allowlist entry describing an external MCP server darnit may spawn. @@ -1506,6 +1576,12 @@ class FrameworkConfig(BaseModel): # backward-compatible behavior for every existing framework TOML. mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict) + # Per-artifact persistence backend selection (feature 033). Any unset + # field means "use the filesystem default" for that artifact class; + # a `.baseline.toml` block for a given kind fully replaces the + # framework TOML block for that kind (per-kind replacement). + stores: StoresConfig = Field(default_factory=StoresConfig) + # Named audit profiles (optional, for multi-scenario implementations) audit_profiles: dict[str, AuditProfileConfig] = Field(default_factory=dict) diff --git a/packages/darnit/src/darnit/config/merger.py b/packages/darnit/src/darnit/config/merger.py index b8221fd7..5c3adcaf 100644 --- a/packages/darnit/src/darnit/config/merger.py +++ b/packages/darnit/src/darnit/config/merger.py @@ -74,6 +74,7 @@ FrameworkConfig, FrameworkDefaults, McpServerConfig, + StoresConfig, ) from .user_schema import ( ControlOverride, @@ -166,6 +167,12 @@ class EffectiveConfig: # default and preserves backward compatibility. mcp_servers: dict[str, "McpServerConfig"] = field(default_factory=dict) + # Feature 033: merged per-artifact persistence backend selection. + # `.baseline.toml`'s `[stores.]` for a given kind fully + # replaces the framework TOML block for that kind (per-kind + # replacement, disjoint kinds coexist). + stores: "StoresConfig | None" = None + # Source configs (for reference) _framework_config: FrameworkConfig | None = None _user_config: UserConfig | None = None @@ -392,6 +399,21 @@ def merge_configs( for name, srv in user.mcp_servers.items(): effective.mcp_servers[name] = srv + # Merge persistence backend selection (feature 033). + # Per-kind replacement: `.baseline.toml`'s [stores.] block for + # a given kind fully replaces the framework TOML block for that + # kind. Disjoint kinds coexist. + from .framework_schema import StoresConfig as _StoresConfig + + merged_stores_data: dict[str, Any] = {} + for kind in ("project", "attestation", "report", "cache"): + fw_block = getattr(framework.stores, kind, None) + user_block = getattr(user.stores, kind, None) if user else None + block = user_block if user_block is not None else fw_block + if block is not None: + merged_stores_data[kind] = block + effective.stores = _StoresConfig.model_construct(**merged_stores_data) + # Apply user settings if user: effective.cache_results = user.settings.cache_results diff --git a/packages/darnit/src/darnit/config/user_schema.py b/packages/darnit/src/darnit/config/user_schema.py index f86a1beb..90771893 100644 --- a/packages/darnit/src/darnit/config/user_schema.py +++ b/packages/darnit/src/darnit/config/user_schema.py @@ -46,6 +46,7 @@ HandlerInvocation, McpServerConfig, RemediationConfig, + StoresConfig, ) # ============================================================================= @@ -263,6 +264,11 @@ class UserConfig(BaseModel): # time (spec FR-016); disjoint names coexist. mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict) + # Per-fleet persistence backend selection (feature 033). Any kind + # set here fully replaces the framework's `[stores.]` block at + # merge time; unset kinds inherit from the framework. + stores: StoresConfig = Field(default_factory=StoresConfig) + model_config = ConfigDict(extra="allow") # ========================================================================= diff --git a/packages/darnit/src/darnit/context/dot_project.py b/packages/darnit/src/darnit/context/dot_project.py index ccf9ad00..95fe5365 100644 --- a/packages/darnit/src/darnit/context/dot_project.py +++ b/packages/darnit/src/darnit/context/dot_project.py @@ -359,18 +359,29 @@ class DotProjectReader: Implements tolerant parsing that preserves unknown fields for forward compatibility with spec evolution. + + Feature 033: accepts an optional :class:`~darnit.stores.protocols.ProjectStateStore`. + When a store is provided, ``read()`` and related methods delegate to + the store's ``read_project()`` / ``read_maintainers()``. When None, + the reader falls back to direct filesystem I/O on ``/.project/`` + (the pre-feature behavior). The filesystem default store's own + ``read_project()`` constructs a ``DotProjectReader`` with ``store=None`` + to break the recursion. """ - def __init__(self, repo_path: str | Path): + def __init__(self, repo_path: str | Path, store: object | None = None): """Initialize reader with repository path. Args: repo_path: Path to the repository root + store: Optional ProjectStateStore. When provided, reads route + through the store; when None, direct filesystem I/O. """ self.repo_path = Path(repo_path) self.project_dir = self.repo_path / ".project" self.project_yaml = self.project_dir / "project.yaml" self.maintainers_yaml = self.project_dir / "maintainers.yaml" + self._store = store def exists(self) -> bool: """Check if .project/project.yaml exists.""" @@ -385,6 +396,13 @@ def read(self) -> ProjectConfig: Raises: ValueError: If YAML parsing fails """ + # Feature 033: store-first path. When a store is bound, delegate. + if self._store is not None: + config = self._store.read_project() + if config is None: + return ProjectConfig() + return config + if not self.exists(): logger.debug("No .project/project.yaml found at %s", self.repo_path) return ProjectConfig() diff --git a/packages/darnit/src/darnit/context/dot_project_mapper.py b/packages/darnit/src/darnit/context/dot_project_mapper.py index 58b129a7..4d2ae33a 100644 --- a/packages/darnit/src/darnit/context/dot_project_mapper.py +++ b/packages/darnit/src/darnit/context/dot_project_mapper.py @@ -50,16 +50,25 @@ class DotProjectMapper: ``.project`` repository and merges it with the local config. """ - def __init__(self, repo_path: str | Path, *, owner: str = ""): + def __init__( + self, + repo_path: str | Path, + *, + owner: str = "", + project_store: object | None = None, + ): """Initialize mapper with repository path. Args: repo_path: Path to the repository root owner: GitHub org/user for org-level .project resolution + project_store: Optional ProjectStateStore (feature 033). When + provided, project/maintainer reads route through the + store instead of direct filesystem I/O. """ self.repo_path = Path(repo_path) self.owner = owner - self.reader = DotProjectReader(repo_path) + self.reader = DotProjectReader(repo_path, store=project_store) self._config: ProjectConfig | None = None self._context: dict[str, Any] | None = None diff --git a/packages/darnit/src/darnit/core/env_subst.py b/packages/darnit/src/darnit/core/env_subst.py new file mode 100644 index 00000000..7cdcabfd --- /dev/null +++ b/packages/darnit/src/darnit/core/env_subst.py @@ -0,0 +1,98 @@ +"""Shared ``$VAR`` substitution helper. + +Feature 033 (research decision R-004) extracted this routine from where it +was duplicated in feature 025's ``exec`` handler and feature 031's mcp +``env`` block. Consumers migrate to this shared implementation via +T005/T006; new consumers (feature 033's ``[stores.]`` TOML blocks) +call it directly. + +Semantics chosen to match the previous behavior of both existing call +sites so no downstream behavior changes: + +* ``$VAR`` occurrences substitute the value from ``env``. +* ``$$`` is a literal ``$`` (escape). +* Non-alphanumeric-underscore characters after ``$`` terminate the + variable name, so ``$FOO/bar`` yields ``value(FOO) + "/bar"``. +* When ``missing_ok=True`` (default), unset variables substitute as + empty string. Matches features 025/031 semantics. +* When ``missing_ok=False``, unset variables raise ``KeyError()``. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from typing import Literal + +__all__ = ["substitute_dollar_vars"] + + +MissingMode = Literal["empty", "raise", "leave"] + + +def substitute_dollar_vars( + template: str, + env: Mapping[str, str] | None = None, + *, + missing: MissingMode = "empty", +) -> str: + """Substitute ``$VAR`` occurrences in ``template`` with values from ``env``. + + Args: + template: The string to scan for ``$VAR`` tokens. + env: Mapping from variable name to value. Defaults to + :data:`os.environ`. + missing: Behavior for unset variables. One of: + + * ``"empty"`` (default) -- substitute as empty string. Matches + features 025 and 031 semantics. + * ``"raise"`` -- raise ``KeyError()``. + * ``"leave"`` -- keep the ``$NAME`` literal in the output. + Matches the previous behavior of the mcp-handler + ``_apply_replacements`` helper for tokens not in a bounded + replacement dict. + + Returns: + The substituted string. + + Raises: + KeyError: When ``missing="raise"`` and a variable is unset. + """ + if env is None: + env = os.environ + + result: list[str] = [] + i = 0 + n = len(template) + while i < n: + ch = template[i] + if ch != "$": + result.append(ch) + i += 1 + continue + # `$` -- check what follows + if i + 1 < n and template[i + 1] == "$": + # $$ -> literal $ + result.append("$") + i += 2 + continue + # Scan the variable name (alphanumerics + underscore) + end = i + 1 + while end < n and (template[end].isalnum() or template[end] == "_"): + end += 1 + if end == i + 1: + # Lone `$` with nothing name-like after it: keep as literal + result.append("$") + i += 1 + continue + varname = template[i + 1 : end] + if varname in env: + result.append(env[varname]) + elif missing == "empty": + pass # substitute empty string + elif missing == "leave": + result.append(template[i:end]) # keep `$NAME` literal + else: # missing == "raise" + raise KeyError(varname) + i = end + return "".join(result) diff --git a/packages/darnit/src/darnit/core/models.py b/packages/darnit/src/darnit/core/models.py index 2b492144..99aec136 100644 --- a/packages/darnit/src/darnit/core/models.py +++ b/packages/darnit/src/darnit/core/models.py @@ -106,6 +106,13 @@ class ExecutionContext: # MCP server pay zero cost. mcp_servers: dict[str, Any] = field(default_factory=dict) + # Feature 033: pluggable-stores bundle (four backends: project state, + # attestation, report, audit cache). Typed as Any to avoid an + # import cycle with darnit.stores. Populated by run_sieve_audit + # after resolving the effective stores config; None on paths that + # bypass the audit driver (legacy or unit-test constructions). + stores: Any = None + # Threading locks _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) _tool_locks: dict[str, threading.Lock] = field(default_factory=dict, init=False, repr=False) diff --git a/packages/darnit/src/darnit/sieve/builtin_handlers.py b/packages/darnit/src/darnit/sieve/builtin_handlers.py index 607cf0e0..402065ad 100644 --- a/packages/darnit/src/darnit/sieve/builtin_handlers.py +++ b/packages/darnit/src/darnit/sieve/builtin_handlers.py @@ -1145,7 +1145,16 @@ def _lookup_mcp_server(context: HandlerContext, server_name: str) -> Any | None: def _substitute_mcp_args(args: dict[str, Any], context: HandlerContext) -> dict[str, Any]: - """Substitute ``$OWNER``/``$REPO``/``$BRANCH``/``$PATH`` in string values.""" + """Substitute ``$OWNER``/``$REPO``/``$BRANCH``/``$PATH`` in string values. + + Feature 033 T005: uses :func:`darnit.core.env_subst.substitute_dollar_vars` + with ``missing="leave"`` semantics so unknown ``$VAR`` tokens in the + template are preserved as-is (matches the previous + ``_apply_replacements`` behavior). Only the four context-derived + tokens are substituted. + """ + from darnit.core.env_subst import substitute_dollar_vars + replacements = { "OWNER": context.owner or "", "REPO": context.repo or "", @@ -1155,32 +1164,12 @@ def _substitute_mcp_args(args: dict[str, Any], context: HandlerContext) -> dict[ out: dict[str, Any] = {} for key, value in args.items(): if isinstance(value, str): - out[key] = _apply_replacements(value, replacements) + out[key] = substitute_dollar_vars(value, replacements, missing="leave") else: out[key] = value return out -def _apply_replacements(template: str, replacements: dict[str, str]) -> str: - result: list[str] = [] - i = 0 - while i < len(template): - ch = template[i] - if ch == "$" and i + 1 < len(template): - end = i + 1 - while end < len(template) and (template[end].isalnum() or template[end] == "_"): - end += 1 - if end > i + 1: - name = template[i + 1 : end] - if name in replacements: - result.append(replacements[name]) - i = end - continue - result.append(ch) - i += 1 - return "".join(result) - - def _eval_cel_over_result( expr: str, raw_response: dict[str, Any] ) -> tuple[bool, Any, str | None]: diff --git a/packages/darnit/src/darnit/sieve/mcp_pool.py b/packages/darnit/src/darnit/sieve/mcp_pool.py index 23421f4a..71966351 100644 --- a/packages/darnit/src/darnit/sieve/mcp_pool.py +++ b/packages/darnit/src/darnit/sieve/mcp_pool.py @@ -521,23 +521,16 @@ def _absent_binary_message(program: str, config: Any) -> str: def _substitute_dollar_vars(template: str, env: dict[str, str]) -> str: - """Replace ``$VAR`` occurrences with values from ``env``; empty if unset.""" - result: list[str] = [] - i = 0 - while i < len(template): - ch = template[i] - if ch == "$" and i + 1 < len(template): - end = i + 1 - while end < len(template) and (template[end].isalnum() or template[end] == "_"): - end += 1 - if end > i + 1: - name = template[i + 1 : end] - result.append(env.get(name, "")) - i = end - continue - result.append(ch) - i += 1 - return "".join(result) + """Thin shim over :func:`darnit.core.env_subst.substitute_dollar_vars`. + + Feature 033 T006 migrated this call site from the duplicated inline + implementation to the shared helper. Kept as a shim so the internal + ``mcp_pool`` call sites remain unchanged; the shim can be removed + when the module is next touched. + """ + from darnit.core.env_subst import substitute_dollar_vars + + return substitute_dollar_vars(template, env, missing="empty") def _first_text(parts: list[Any]) -> str | None: diff --git a/packages/darnit/src/darnit/stores/__init__.py b/packages/darnit/src/darnit/stores/__init__.py new file mode 100644 index 00000000..35bba19c --- /dev/null +++ b/packages/darnit/src/darnit/stores/__init__.py @@ -0,0 +1,52 @@ +"""Pluggable per-artifact persistence Protocols. + +Feature 033: this sub-package defines four ``typing.Protocol`` classes -- +:class:`ProjectStateStore`, :class:`AttestationStore`, :class:`ReportStore`, +:class:`AuditCacheStore` -- each of which sits at an audit-boundary +composition point. Filesystem-backed default implementations ship in +:mod:`darnit.stores.defaults`; alternative backends are third-party plugin +packages that register under ``darnit.stores.`` entry-point groups +(pattern reused from feature 027's ``darnit.question_resolvers``). + +The whole abstraction is orthogonal to the sieve pipeline. Store access +happens ONLY at audit-boundary composition (audit driver, remediation +orchestrator, attestation generator, `.project/` reader/writer, audit-cache +reader/writer). Sieve handlers, remediation handlers, and MCP tools MUST +NOT import from this sub-package (FR-017, enforced mechanically by +``tests/darnit/stores/test_import_isolation.py``). + +Every Protocol declares ``close()`` (FR-019). The framework calls +``close()`` exactly once at audit-boundary tear-down via +:class:`_StoreBundle.close_all` -- matches feature 031's +``McpPool.teardown_all()`` pattern for a wider per-audit boundary. +""" + +from __future__ import annotations + +from darnit.stores.errors import ( + StoreError, + StoreNameCollision, + StoreNotInstalled, + StoreOperationError, + StoreProtocolMismatch, +) +from darnit.stores.protocols import ( + AttestationStore, + AuditCacheStore, + ProjectStateStore, + ReportStore, + Store, +) + +__all__ = [ + "AttestationStore", + "AuditCacheStore", + "ProjectStateStore", + "ReportStore", + "Store", + "StoreError", + "StoreNameCollision", + "StoreNotInstalled", + "StoreOperationError", + "StoreProtocolMismatch", +] diff --git a/packages/darnit/src/darnit/stores/defaults/__init__.py b/packages/darnit/src/darnit/stores/defaults/__init__.py new file mode 100644 index 00000000..aab2b5a7 --- /dev/null +++ b/packages/darnit/src/darnit/stores/defaults/__init__.py @@ -0,0 +1,20 @@ +"""Filesystem-backed default implementations of the store Protocols. + +Feature 033 T016. Each default reproduces the pre-feature on-disk layout +exactly (SC-003), so an audit with no ``[stores.*]`` block behaves +identically to how darnit did before this feature landed. +""" + +from __future__ import annotations + +from darnit.stores.defaults.attestation import FilesystemAttestationStore +from darnit.stores.defaults.cache import FilesystemAuditCacheStore +from darnit.stores.defaults.project import FilesystemProjectStateStore +from darnit.stores.defaults.report import FilesystemReportStore + +__all__ = [ + "FilesystemAttestationStore", + "FilesystemAuditCacheStore", + "FilesystemProjectStateStore", + "FilesystemReportStore", +] diff --git a/packages/darnit/src/darnit/stores/defaults/attestation.py b/packages/darnit/src/darnit/stores/defaults/attestation.py new file mode 100644 index 00000000..7bc23eec --- /dev/null +++ b/packages/darnit/src/darnit/stores/defaults/attestation.py @@ -0,0 +1,56 @@ +"""Filesystem-backed ``AttestationStore`` default. + +Feature 033 T016. Writes each attestation bundle to +``/.`` where ```` derives from the +content_type argument. Reproduces the pre-feature on-disk layout +(``.darnit/attestations/`` under a repo root). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from darnit.stores.errors import StoreOperationError + +# Content-type -> filesystem extension. Keeps darnit's on-disk +# convention explicit and stable for downstream tooling (Sigstore +# verifiers, in-toto readers) that grep for these suffixes. +_CONTENT_TYPE_EXT: dict[str, str] = { + "application/vnd.in-toto+json": ".intoto.json", + "application/vnd.dev.sigstore.bundle+json": ".sigstore.json", + "application/json": ".json", +} + + +class FilesystemAttestationStore: + """Write attestation bundles under ``/.``.""" + + def __init__(self, root: Path) -> None: + self._root = Path(root) + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + ext = _CONTENT_TYPE_EXT.get(content_type, ".bin") + safe_id = _sanitize_filename(bundle_id) + target = self._root / f"{safe_id}{ext}" + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(bundle_bytes) + except OSError as err: + raise StoreOperationError( + f"failed to write attestation bundle to {target}: {err}" + ) from err + + def close(self) -> None: + return None + + +_FILENAME_UNSAFE = re.compile(r"[^A-Za-z0-9._+@-]") + + +def _sanitize_filename(name: str) -> str: + """Replace filesystem-unsafe characters with `_` for cross-platform safety.""" + return _FILENAME_UNSAFE.sub("_", name) or "unnamed" + + +__all__ = ["FilesystemAttestationStore"] diff --git a/packages/darnit/src/darnit/stores/defaults/cache.py b/packages/darnit/src/darnit/stores/defaults/cache.py new file mode 100644 index 00000000..929e1ad2 --- /dev/null +++ b/packages/darnit/src/darnit/stores/defaults/cache.py @@ -0,0 +1,76 @@ +"""Filesystem-backed ``AuditCacheStore`` default. + +Feature 033 T016. Reads/writes cache envelopes under +``/.json`` with tempfile-then-rename atomic +semantics (matches the pre-feature behavior of +:mod:`darnit.core.audit_cache`). + +MUST NOT raise on read or write per the AuditCacheStore Protocol's +best-effort contract; failures return None (read) or are logged and +swallowed (write). +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from darnit.core.logging import get_logger +from darnit.stores.defaults.attestation import _sanitize_filename + +logger = get_logger("stores.defaults.cache") + + +class FilesystemAuditCacheStore: + """Read/write cache envelopes under ````.""" + + def __init__(self, root: Path) -> None: + self._root = Path(root) + + def _path(self, cache_key: str) -> Path: + return self._root / f"{_sanitize_filename(cache_key)}.json" + + def read(self, cache_key: str) -> dict[str, Any] | None: + target = self._path(cache_key) + if not target.exists(): + return None + try: + with open(target, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as err: + logger.debug("audit cache read failed for %s: %s", target, err) + return None + + def write(self, cache_key: str, envelope: dict[str, Any]) -> None: + target = self._path(cache_key) + try: + target.parent.mkdir(parents=True, exist_ok=True) + # Atomic write: tempfile then rename (matches pre-feature + # semantics from darnit.core.audit_cache). + fd, tmp_path = tempfile.mkstemp( + dir=str(target.parent), + suffix=".tmp", + prefix="audit-cache-", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(envelope, f, indent=2) + os.replace(tmp_path, str(target)) + except Exception: + # Best-effort cleanup on rename failure. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + except OSError as err: + logger.warning("audit cache write failed for %s: %s", target, err) + + def close(self) -> None: + return None + + +__all__ = ["FilesystemAuditCacheStore"] diff --git a/packages/darnit/src/darnit/stores/defaults/project.py b/packages/darnit/src/darnit/stores/defaults/project.py new file mode 100644 index 00000000..c5b3efae --- /dev/null +++ b/packages/darnit/src/darnit/stores/defaults/project.py @@ -0,0 +1,92 @@ +"""Filesystem-backed ``ProjectStateStore`` default. + +Feature 033 T016. Reads/writes ``.project/project.yaml`` and +``.project/maintainers.yaml`` on the local filesystem. The reader +delegates to the existing :class:`darnit.context.dot_project.DotProjectReader` +via a construction shape that avoids the store-routing path (so the +default backend does not recurse through itself). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from darnit.stores.errors import StoreOperationError + +if TYPE_CHECKING: + from darnit.context.dot_project import MaintainerEntry, ProjectConfig + + +class FilesystemProjectStateStore: + """Read/write ``.project/`` YAML files on the local filesystem. + + Args: + repo_path: Root of the repository whose ``.project/`` this store + reads and writes. + """ + + def __init__(self, repo_path: Path) -> None: + self._repo_path = Path(repo_path) + + @property + def project_yaml(self) -> Path: + return self._repo_path / ".project" / "project.yaml" + + @property + def maintainers_yaml(self) -> Path: + return self._repo_path / ".project" / "maintainers.yaml" + + def read_project(self) -> ProjectConfig | None: + # Lazy import to avoid circulars during darnit.stores package load. + from darnit.context.dot_project import DotProjectReader + + if not self.project_yaml.exists(): + return None + try: + # DotProjectReader's default path does raw filesystem I/O; the + # store-aware constructor kwarg lands in T021 and the reader + # will route BACK through the store's raw-I/O path, which is + # this function's callers. To break the cycle, this default + # backend reads YAML directly rather than routing through the + # reader's store hook. The reader's YAML parsing is exposed + # via its `_parse_yaml_files` internal path. + reader = DotProjectReader(self._repo_path) + return reader.read() + except Exception as err: # noqa: BLE001 + raise StoreOperationError( + f"failed to read {self.project_yaml}: {err}" + ) from err + + def write_project(self, config: ProjectConfig) -> None: + # v0 write path piggybacks on the existing DotProjectWriter (which + # serializes ProjectConfig -> project.yaml). Alternative backends + # override this. + from darnit.context.dot_project import DotProjectWriter + + try: + writer = DotProjectWriter(self._repo_path) + writer.write(config) + except Exception as err: # noqa: BLE001 + raise StoreOperationError( + f"failed to write {self.project_yaml}: {err}" + ) from err + + def read_maintainers(self) -> list[MaintainerEntry]: + config = self.read_project() + if config is None: + return [] + return list(getattr(config, "maintainer_entries", [])) + + def write_maintainers(self, entries: list[MaintainerEntry]) -> None: + from darnit.context.dot_project import ProjectConfig + + existing = self.read_project() or ProjectConfig(name="") + existing.maintainer_entries = list(entries) + self.write_project(existing) + + def close(self) -> None: + return None + + +__all__ = ["FilesystemProjectStateStore"] diff --git a/packages/darnit/src/darnit/stores/defaults/report.py b/packages/darnit/src/darnit/stores/defaults/report.py new file mode 100644 index 00000000..6c2e3979 --- /dev/null +++ b/packages/darnit/src/darnit/stores/defaults/report.py @@ -0,0 +1,45 @@ +"""Filesystem-backed ``ReportStore`` default. + +Feature 033 T016. Writes each format to +``/.{md,json,sarif}``. +""" + +from __future__ import annotations + +from pathlib import Path + +from darnit.stores.defaults.attestation import _sanitize_filename +from darnit.stores.errors import StoreOperationError + + +class FilesystemReportStore: + """Write audit reports (Markdown/JSON/SARIF) under ````.""" + + def __init__(self, root: Path) -> None: + self._root = Path(root) + + def _write(self, report_id: str, ext: str, content: str) -> None: + safe_id = _sanitize_filename(report_id) + target = self._root / f"{safe_id}{ext}" + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + except OSError as err: + raise StoreOperationError( + f"failed to write report to {target}: {err}" + ) from err + + def write_markdown(self, report_id: str, content: str) -> None: + self._write(report_id, ".md", content) + + def write_json(self, report_id: str, content: str) -> None: + self._write(report_id, ".json", content) + + def write_sarif(self, report_id: str, content: str) -> None: + self._write(report_id, ".sarif", content) + + def close(self) -> None: + return None + + +__all__ = ["FilesystemReportStore"] diff --git a/packages/darnit/src/darnit/stores/discovery.py b/packages/darnit/src/darnit/stores/discovery.py new file mode 100644 index 00000000..77993be3 --- /dev/null +++ b/packages/darnit/src/darnit/stores/discovery.py @@ -0,0 +1,121 @@ +"""Entry-point discovery for pluggable store backends. + +Feature 033 T014 (research decision R-003). Matches the pattern feature +027's :mod:`darnit.harness.resolver_discovery` established for the +``darnit.question_resolvers`` group, adapted for the four store groups +plus name-collision detection (FR-009). + +Discovery runs exactly once per process. Results are cached in a +module-level dict keyed by entry-point group name; subsequent calls +return the cached mapping. This locks FR-005's "at framework-load time" +contract without requiring the caller to cache separately. +""" + +from __future__ import annotations + +from importlib import metadata +from typing import TYPE_CHECKING + +from darnit.core.logging import get_logger +from darnit.stores.errors import StoreNameCollision + +if TYPE_CHECKING: + from darnit.stores.protocols import Store + +logger = get_logger("stores.discovery") + +STORE_ENTRY_POINT_GROUPS: tuple[str, ...] = ( + "darnit.stores.project", + "darnit.stores.attestation", + "darnit.stores.report", + "darnit.stores.cache", +) + +_DISCOVERY_CACHE: dict[str, dict[str, type[Store]]] = {} + + +def discover_stores(group: str) -> dict[str, type[Store]]: + """Discover backend classes registered under ``group``. + + Returns a mapping from entry-point name to the backend class the + entry point points at. The class is loaded but NOT instantiated; + instantiation happens in :mod:`darnit.stores.selection` with the + backend-specific kwargs from the operator's TOML block. + + Broken entry points (raise on ``ep.load()``) are logged as WARNING + and skipped -- one bad plugin does not blank the discovery result + for the whole group. Name collisions (FR-009) raise + :class:`StoreNameCollision` immediately; there is no implicit + "last wins" resolution. + + Args: + group: One of :data:`STORE_ENTRY_POINT_GROUPS`. + + Returns: + Mapping ``entry_point_name -> backend_class``. Cached per group + for the process lifetime. + + Raises: + StoreNameCollision: two entry points under ``group`` register + the same short name. + """ + cached = _DISCOVERY_CACHE.get(group) + if cached is not None: + return cached + + found: dict[str, type[Store]] = {} + source: dict[str, str] = {} # name -> package for collision messages + + try: + eps = metadata.entry_points(group=group) + except TypeError: + # Python 3.9 kwargs-not-supported shape; darnit requires 3.11+ + # so this is a defensive fallback. + eps = [ + ep + for ep in metadata.entry_points() # type: ignore[call-arg] + if getattr(ep, "group", None) == group + ] + + for ep in eps: + # Resolve the source package name for collision messages. + pkg = getattr(ep, "dist", None) + pkg_name = pkg.metadata["Name"] if pkg is not None else str(ep.value) + + if ep.name in found: + raise StoreNameCollision( + group=group, + name=ep.name, + first=source.get(ep.name, "unknown"), + second=pkg_name, + ) + + try: + cls = ep.load() + except Exception as exc: # noqa: BLE001 + logger.warning( + "store entry point %r under %s failed to load: %s: %s", + ep.name, + group, + type(exc).__name__, + exc, + ) + continue + + found[ep.name] = cls + source[ep.name] = pkg_name + + _DISCOVERY_CACHE[group] = found + return found + + +def _reset_discovery_cache() -> None: + """Test-only helper to clear the discovery cache. + + Not part of the public API. Tests that mock entry-point registration + call this between cases so each case sees a fresh discovery. + """ + _DISCOVERY_CACHE.clear() + + +__all__ = ["STORE_ENTRY_POINT_GROUPS", "discover_stores"] diff --git a/packages/darnit/src/darnit/stores/errors.py b/packages/darnit/src/darnit/stores/errors.py new file mode 100644 index 00000000..5abfa8d1 --- /dev/null +++ b/packages/darnit/src/darnit/stores/errors.py @@ -0,0 +1,108 @@ +"""Exception hierarchy for the pluggable-stores abstraction (feature 033). + +Each concrete class corresponds to a spec-level Functional Requirement: + +* :class:`StoreNotInstalled` -- FR-008 (fail-fast on unresolvable backend). +* :class:`StoreProtocolMismatch` -- FR-002 + FR-008 (runtime Protocol check). +* :class:`StoreNameCollision` -- FR-009 (two plugins register the same name). +* :class:`StoreOperationError` -- FR-011 (backend-side operational failure). + +All four inherit from :class:`StoreError`, the base class the framework can +catch when it wants to distinguish store-side failures from arbitrary +Python errors. +""" + +from __future__ import annotations + + +class StoreError(Exception): + """Base class for every store-related failure.""" + + +class StoreNotInstalled(StoreError): + """Selected backend is not registered under the target entry-point group. + + Raised by the selection layer (:mod:`darnit.stores.selection`) at + framework-config load time, per FR-008 (fail-fast on unresolvable + backend selection). The framework MUST NOT silently fall back to the + filesystem default (FR-012); the operator's selection is honored to + the point of failure. + """ + + def __init__(self, group: str, name: str, available: list[str]) -> None: + alternatives = ", ".join(sorted(available)) if available else "(none installed)" + super().__init__( + f"no store registered under {group!r} with name {name!r}; " + f"installed alternatives: {alternatives}" + ) + self.group = group + self.name = name + self.available = list(available) + + +class StoreProtocolMismatch(StoreError): + """Registered class does not satisfy the target Protocol. + + Raised at selection / instantiation time when + ``isinstance(instance, ProtocolClass)`` fails, per FR-002 (runtime + Protocol conformance) and FR-008 (fail-fast). + """ + + def __init__(self, group: str, name: str, cls: type, missing: list[str]) -> None: + super().__init__( + f"{group}/{name} -> {cls.__module__}.{cls.__qualname__} does not " + f"satisfy the Protocol; missing methods: " + f"{', '.join(missing) if missing else 'unknown'}" + ) + self.group = group + self.name = name + self.cls = cls + self.missing = list(missing) + + +class StoreNameCollision(StoreError): + """Two entry points register the same short name under one group. + + Raised by :func:`darnit.stores.discovery.discover_stores` at framework- + load time, per FR-009. No implicit "last wins" resolution -- operator + disambiguation is required. + """ + + def __init__(self, group: str, name: str, first: str, second: str) -> None: + super().__init__( + f"two entry points register {name!r} under {group!r}: {first} vs " + f"{second}. Uninstall one, or rename the entry point in one " + f"plugin's pyproject.toml." + ) + self.group = group + self.name = name + self.first = first + self.second = second + + +class StoreOperationError(StoreError): + """Backend-side operational failure at read or write time. + + Raised by store implementations to surface a failed operation to the + caller (per FR-011). Caller code interprets according to the per- + Protocol failure semantics documented in + ``specs/033-pluggable-stores/contracts/*.md``: + + * :class:`darnit.stores.ProjectStateStore` -- read failure -> caller + resolves affected controls WARN; write failure -> audit-run error. + * :class:`darnit.stores.AttestationStore` -- write failure -> audit-run + error with the store surfaced. + * :class:`darnit.stores.ReportStore` -- write failure -> audit-run error + with the format name. + * :class:`darnit.stores.AuditCacheStore` -- MUST NOT raise; caller code + catches and logs. Best-effort. + """ + + +__all__ = [ + "StoreError", + "StoreNameCollision", + "StoreNotInstalled", + "StoreOperationError", + "StoreProtocolMismatch", +] diff --git a/packages/darnit/src/darnit/stores/protocols.py b/packages/darnit/src/darnit/stores/protocols.py new file mode 100644 index 00000000..cf961169 --- /dev/null +++ b/packages/darnit/src/darnit/stores/protocols.py @@ -0,0 +1,215 @@ +"""Store Protocols (feature 033 T008). + +Five Protocols implementing the persistence extension surface: + +* :class:`Store` -- the shared ``close()`` contract every store carries. +* :class:`ProjectStateStore` -- ``.project/`` project + maintainers I/O. +* :class:`AttestationStore` -- write-only attestation-bundle persistence. +* :class:`ReportStore` -- write-only Markdown/JSON/SARIF audit-report + persistence. +* :class:`AuditCacheStore` -- read + write for the per-audit-run cache. + +Every Protocol is decorated with :func:`typing.runtime_checkable` so +``isinstance(instance, ProtocolClass)`` is a fast Protocol-conformance +check (FR-002). The framework uses this at :func:`darnit.stores.selection.resolve_stores` +time to fail-fast on plugin classes that do not satisfy the Protocol. + +See ``specs/033-pluggable-stores/contracts/`` for the per-Protocol contract +docs (TOML surface, method-by-method contracts, per-Protocol failure +semantics from FR-011, and non-goals for v0). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + # Forward references avoid circular import between stores and context. + from darnit.context.dot_project import MaintainerEntry, ProjectConfig + + +@runtime_checkable +class Store(Protocol): + """Base Protocol carrying the shared ``close()`` contract (FR-019). + + Every concrete Store Protocol inherits from this. The framework calls + :meth:`close` exactly once per instantiated store at audit-boundary + tear-down via :meth:`darnit.stores.selection._StoreBundle.close_all`. + """ + + def close(self) -> None: + """Release any resources held by this store. + + Contract (FR-019): + + * MUST be idempotent -- a second call is a no-op, not an error. + * MUST NOT raise on the "already closed" case. + * MAY raise on unrecoverable teardown failure (network partition, + disk full during flush). The framework wraps the call in + try/except and logs; it does NOT re-raise. + """ + ... + + +@runtime_checkable +class ProjectStateStore(Store, Protocol): + """Read + write for ``.project/project.yaml`` and ``.project/maintainers.yaml``. + + Failure semantics (FR-011): + + * ``read_project`` / ``read_maintainers`` failure -> caller resolves + affected controls WARN (never silent PASS, never silent FAIL). + * ``write_project`` / ``write_maintainers`` failure -> caller + surfaces the error to the operator; the audit-run fails. + + Concurrency: sync in v0. Single-caller assumed within an audit run. + """ + + def read_project(self) -> ProjectConfig | None: + """Load the project configuration. Return None if not present. + + Raises: + StoreOperationError: on backend failure that is not "not found". + """ + ... + + def write_project(self, config: ProjectConfig) -> None: + """Persist the project configuration. + + Preconditions: + ``config`` is a valid :class:`~darnit.context.dot_project.ProjectConfig`. + + Raises: + StoreOperationError: on backend failure. + """ + ... + + def read_maintainers(self) -> list[MaintainerEntry]: + """Load the maintainer entries. Empty list if none present. + + Raises: + StoreOperationError: on backend failure that is not "not found". + """ + ... + + def write_maintainers(self, entries: list[MaintainerEntry]) -> None: + """Persist the maintainer entries. + + Raises: + StoreOperationError: on backend failure. + """ + ... + + +@runtime_checkable +class AttestationStore(Store, Protocol): + """Write-only surface for attestation bundles. + + Failure semantics (FR-011): a raise on ``write`` surfaces as an + audit-run error naming the store; the attestation is NOT reported as + persisted. Rationale: an attestation that quietly failed to persist + is a compliance record that quietly didn't happen. Constitution II + demands the error be visible. + + Read-back is NOT in v0. Attestations are consumed downstream by + other tooling (Sigstore, in-toto verifiers); if darnit needs to + enumerate its own attestations, add ``list_bundles()`` as an + additive Protocol extension. + """ + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + """Persist an attestation bundle. + + Args: + bundle_id: Stable, filesystem-safe identifier that correlates + the bundle with the audit run that produced it. + bundle_bytes: The serialized attestation. + content_type: Media type (e.g., ``"application/vnd.in-toto+json"``, + ``"application/vnd.dev.sigstore.bundle+json"``). + + Raises: + StoreOperationError: on any backend failure. + """ + ... + + +@runtime_checkable +class ReportStore(Store, Protocol): + """Write surface for audit reports in the three supported formats. + + Format-specific methods (rather than a generic ``write(format, content)``) + so the "three formats, always these three" invariant is enforceable + by mypy/pyright. Adding a fourth format is an additive Protocol + change. + + v0 has no existing report-writing call site to migrate. The Protocol + exists so downstream features (starting with #341) can write through + the Protocol without having to introduce the abstraction + retroactively. + + Failure semantics (FR-011): a raise on ``write_*`` surfaces as an + audit-run error naming the format. + """ + + def write_markdown(self, report_id: str, content: str) -> None: + """Persist a Markdown-formatted audit report. + + Raises: + StoreOperationError: on backend failure. + """ + ... + + def write_json(self, report_id: str, content: str) -> None: + """Persist a JSON audit report. + + Raises: + StoreOperationError: on backend failure. + """ + ... + + def write_sarif(self, report_id: str, content: str) -> None: + """Persist a SARIF-formatted audit report. + + Raises: + StoreOperationError: on backend failure. + """ + ... + + +@runtime_checkable +class AuditCacheStore(Store, Protocol): + """Read + write for the per-audit-run cache. + + Failure semantics (FR-011): cache is **best-effort**. Both + :meth:`read` and :meth:`write` MUST NOT raise. Backend failures are + swallowed by the caller (read returns cache-miss; write is logged + and the audit continues). Rationale: cache is a performance + optimization, not a correctness requirement. A failing cache write + leads to an extra audit run -- a slowdown, not a compliance error. + + TTL semantics live in the caller (see :mod:`darnit.core.audit_cache`); + the store is a dumb read-through / write-through KV. + """ + + def read(self, cache_key: str) -> dict[str, Any] | None: + """Load a cache envelope. Return None on miss OR on backend failure. + + MUST NOT raise. + """ + ... + + def write(self, cache_key: str, envelope: dict[str, Any]) -> None: + """Persist a cache envelope. + + MUST NOT raise. Backend failures are logged; audit continues. + """ + ... + + +__all__ = [ + "AttestationStore", + "AuditCacheStore", + "ProjectStateStore", + "ReportStore", + "Store", +] diff --git a/packages/darnit/src/darnit/stores/selection.py b/packages/darnit/src/darnit/stores/selection.py new file mode 100644 index 00000000..f923fede --- /dev/null +++ b/packages/darnit/src/darnit/stores/selection.py @@ -0,0 +1,237 @@ +"""TOML block -> instantiated backend resolution (feature 033 T015 + T025a). + +Consumes the four ``StoresConfig`` fields, looks up each requested +backend via :mod:`darnit.stores.discovery`, and returns a +:class:`_StoreBundle` whose per-kind fields lazily instantiate the store +on first access. + +Two-phase design (T025a): + +* At ``resolve_stores`` time, we do all *validation*: + discover the plugin, verify its class shape satisfies the target + Protocol, and construct a factory closure. This preserves FR-008 / + SC-007 (unknown backend or Protocol mismatch raises BEFORE any control + runs). +* At first access of ``bundle.project`` / ``.attestation`` / ``.report`` + / ``.cache``, the factory fires and the store is memoized. This + preserves FR-006 / SC-004 (an audit that never touches an artifact + class never constructs its store). + +``close_all()`` iterates only the stores that were actually +instantiated; a bundle whose ``.cache`` was never touched will never +call ``.cache.close()``. Idempotent per FR-019. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from darnit.core.logging import get_logger +from darnit.stores.defaults import ( + FilesystemAttestationStore, + FilesystemAuditCacheStore, + FilesystemProjectStateStore, + FilesystemReportStore, +) +from darnit.stores.discovery import discover_stores +from darnit.stores.errors import StoreNotInstalled, StoreProtocolMismatch +from darnit.stores.protocols import ( + AttestationStore, + AuditCacheStore, + ProjectStateStore, + ReportStore, +) + +logger = get_logger("stores.selection") + +_STORE_KINDS = ("project", "attestation", "report", "cache") + +_KIND_META = { + "project": ("darnit.stores.project", ProjectStateStore), + "attestation": ("darnit.stores.attestation", AttestationStore), + "report": ("darnit.stores.report", ReportStore), + "cache": ("darnit.stores.cache", AuditCacheStore), +} + + +class _StoreBundle: + """Lazy-instantiating holder for the four resolved stores. + + Fields are exposed as read-only properties (``project``, + ``attestation``, ``report``, ``cache``). On first access, the + corresponding factory fires and the returned instance is memoized. + ``close_all()`` closes only stores that were actually accessed. + """ + + def __init__(self, factories: dict[str, Callable[[], Any]]) -> None: + self._factories = dict(factories) + self._instances: dict[str, Any] = {} + + def _get(self, kind: str) -> Any: + if kind not in self._instances: + factory = self._factories.get(kind) + if factory is None: + return None + self._instances[kind] = factory() + return self._instances[kind] + + @property + def project(self) -> ProjectStateStore | None: + return self._get("project") + + @property + def attestation(self) -> AttestationStore | None: + return self._get("attestation") + + @property + def report(self) -> ReportStore | None: + return self._get("report") + + @property + def cache(self) -> AuditCacheStore | None: + return self._get("cache") + + def is_instantiated(self, kind: str) -> bool: + """Return True if ``kind``'s store was actually constructed.""" + return kind in self._instances + + def close_all(self) -> None: + """Call ``close()`` on every INSTANTIATED store, then clear. + + Skips kinds that were never accessed (SC-004: no ghost close on + a store the run never built). Per-store failures are logged and + swallowed. Idempotent (FR-019). + """ + for kind, store in list(self._instances.items()): + try: + store.close() + except Exception as err: # noqa: BLE001 + logger.warning( + "close() on %s store raised %s: %s", + kind, + type(err).__name__, + err, + ) + self._instances.clear() + + +def resolve_stores( + stores_config: Any, + *, + repo_path: Path, + attestation_root: Path | None = None, + report_root: Path | None = None, + cache_root: Path | None = None, +) -> _StoreBundle: + """Validate the four store selections and return a lazy bundle. + + Args: + stores_config: A :class:`darnit.config.framework_schema.StoresConfig` + (or None for zero-config). + repo_path: Repository root; passed to filesystem defaults that + need it. + attestation_root, report_root, cache_root: Optional overrides for + filesystem-default roots. When None, uses + ``/.darnit/{attestations,reports,audit-cache}``. + + Returns: + A :class:`_StoreBundle` whose fields lazily instantiate on first + access. If the run never touches an artifact class its store is + never constructed. + + Raises: + StoreNotInstalled: A selection names a backend not registered. + StoreProtocolMismatch: A registered class does not satisfy the + target Protocol at the class level. + """ + attestation_root = attestation_root or (repo_path / ".darnit" / "attestations") + report_root = report_root or (repo_path / ".darnit" / "reports") + cache_root = cache_root or (repo_path / ".darnit" / "audit-cache") + + default_factories = { + "project": lambda: FilesystemProjectStateStore(repo_path), + "attestation": lambda: FilesystemAttestationStore(attestation_root), + "report": lambda: FilesystemReportStore(report_root), + "cache": lambda: FilesystemAuditCacheStore(cache_root), + } + + factories: dict[str, Callable[[], Any]] = {} + for kind in _STORE_KINDS: + block = None if stores_config is None else getattr(stores_config, kind, None) + if block is None: + factories[kind] = default_factories[kind] + else: + factories[kind] = _validate_and_make_factory( + kind, block, repo_path=repo_path + ) + + return _StoreBundle(factories) + + +def _validate_and_make_factory( + kind: str, block: Any, *, repo_path: Path +) -> Callable[[], Any]: + """Discover the plugin, validate its class shape, return a factory. + + Validation runs eagerly (before the factory fires) so a bad + selection raises before any control runs (FR-008, SC-007). The + factory closure captures kwargs and defers ``cls(...)`` until the + bundle actually needs the store. + """ + group, protocol_cls = _KIND_META[kind] + registered = discover_stores(group) + name = block.backend + if name not in registered: + raise StoreNotInstalled( + group=group, + name=name, + available=list(registered.keys()), + ) + cls = registered[name] + + # Class-shape Protocol check (avoids instantiation). + missing = [ + attr + for attr in _protocol_methods(protocol_cls) + if not hasattr(cls, attr) + ] + if missing: + raise StoreProtocolMismatch( + group=group, + name=name, + cls=cls, + missing=missing, + ) + + kwargs = { + k: v + for k, v in dict(block.model_extra or {}).items() + if k != "backend" + } + kwargs.setdefault("repo_path", repo_path) + + def _factory() -> Any: + try: + return cls(**kwargs) + except TypeError: + fallback = {k: v for k, v in kwargs.items() if k != "repo_path"} + return cls(**fallback) + + return _factory + + +def _protocol_methods(protocol_cls: type) -> list[str]: + """Enumerate the callable attribute names a Protocol requires.""" + names: list[str] = [] + for attr in dir(protocol_cls): + if attr.startswith("_"): + continue + if not callable(getattr(protocol_cls, attr, None)): + continue + names.append(attr) + return names + + +__all__ = ["_StoreBundle", "resolve_stores"] diff --git a/packages/darnit/src/darnit/tools/audit.py b/packages/darnit/src/darnit/tools/audit.py index 30c31b2e..f85576a3 100644 --- a/packages/darnit/src/darnit/tools/audit.py +++ b/packages/darnit/src/darnit/tools/audit.py @@ -144,6 +144,32 @@ def _get_framework_config_path(framework_name: str | None = None) -> Path | None return None +def _load_merged_stores( + local_path: str, framework_name: str | None +) -> Any: + """Return the merged ``StoresConfig`` for this audit run. + + Feature 033. Composes the framework TOML's ``[stores]`` block with + any ``.baseline.toml`` overrides via the per-kind replacement rule + baked into :func:`merge_configs`. Returns ``None`` when no framework + is resolved or neither surface declares any stores; the caller + treats None as "instantiate all filesystem defaults." + """ + from darnit.config import ( + load_framework_config, + load_user_config, + merge_configs, + ) + + framework_path = _get_framework_config_path(framework_name) + if not framework_path: + return None + framework = load_framework_config(framework_path) + user = load_user_config(Path(local_path)) + effective = merge_configs(framework, user) + return getattr(effective, "stores", None) + + def _load_merged_mcp_servers( local_path: str, framework_name: str | None ) -> dict[str, Any]: @@ -529,12 +555,24 @@ def run_sieve_audit( except Exception as e: logger.debug("Auto-detect context failed (non-fatal): %s", e) + # Feature 033: resolve the pluggable-stores bundle for this audit + # run. Zero-config produces filesystem defaults (constitution I). + from darnit.stores.selection import resolve_stores + + stores_config = _load_merged_stores(local_path, resolved_fw) + stores_bundle = resolve_stores(stores_config, repo_path=Path(local_path)) + execution_context.stores = stores_bundle + # Inject .project/ mapper context (between auto-detect and user-confirmed). # Merge order: auto-detect < .project/ mapper < user-confirmed. try: from darnit.context.dot_project_mapper import DotProjectMapper - mapper = DotProjectMapper(local_path, owner=owner or "") + mapper = DotProjectMapper( + local_path, + owner=owner or "", + project_store=stores_bundle.project, + ) mapper_context = mapper.get_context() if mapper_context: project_context.update(mapper_context) @@ -632,6 +670,11 @@ def run_sieve_audit( except Exception as exc: logger.warning("Failed to write audit cache (non-fatal): %s", exc) + # Feature 033: release backend resources at audit-boundary teardown. + # close() implementations are required to be idempotent (FR-019); any + # per-store failure is logged and swallowed by close_all() itself. + stores_bundle.close_all() + return all_results, summary diff --git a/specs/033-pluggable-stores/checklists/requirements.md b/specs/033-pluggable-stores/checklists/requirements.md new file mode 100644 index 00000000..45b70fb3 --- /dev/null +++ b/specs/033-pluggable-stores/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Pluggable storage backends via per-artifact Protocols + +**Purpose**: Validate specification completeness and quality before proceeding to planning + +**Created**: 2026-08-25 + +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Success criteria are technology-agnostic (no implementation details) +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified +- [X] Scope is clearly bounded +- [X] Dependencies and assumptions identified + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification + +## Notes + +- Scope is deliberately narrow: four Protocols, entry-point discovery, TOML selection, filesystem defaults. No non-filesystem backend is built by this feature (the first one lands in #391). +- The spec mentions "Python entry points" and "typing.Protocol" once each as concrete anchors for the reader-contract discussion. These are pattern names that darnit already uses elsewhere (feature 027's `QuestionResolver`) -- consistent with feature 019/029/030 specs that name the classic branch-protection API and the CNCF spec URL directly. They are UPSTREAM ecosystem terms the feature depends on, not internal implementation choices. +- Four user stories: two P1 (correctness + backward-compat), two P2 (ecosystem + failure semantics). All independently testable per the priority guidance. +- Clarifications recorded 2026-08-25: (Q1) `close()` teardown method required on every Protocol, (Q2) `$VAR` substitution for secrets in `[stores.*]` blocks, (Q3) entry-point discovery at framework-load time only. FR-005/FR-006/FR-010 revised; new FR-019 added. +- Ready for `/speckit-plan`. diff --git a/specs/033-pluggable-stores/contracts/attestation-store.md b/specs/033-pluggable-stores/contracts/attestation-store.md new file mode 100644 index 00000000..94cb1b7e --- /dev/null +++ b/specs/033-pluggable-stores/contracts/attestation-store.md @@ -0,0 +1,70 @@ +# Contract: `AttestationStore` Protocol + +**Owner**: `packages/darnit/src/darnit/stores/protocols.py` + +**Registered under**: `darnit.stores.attestation` entry-point group + +**Stability**: Public API. Write-only surface in v0; read-back is an additive extension if needed. + +## Purpose + +Persist attestation bundles produced by darnit-baseline's attestation generator. Attestations are consumed downstream by other tooling (Sigstore, in-toto verifiers, GUAC ingestion), not by darnit itself. + +## TOML surface + +```toml +[stores.attestation] +backend = "s3" +bucket = "my-fleet-attestations" +region = "us-east-1" +access_key_id = "$AWS_ACCESS_KEY_ID" +secret_access_key = "$AWS_SECRET_ACCESS_KEY" +``` + +## Methods + +### `close(self) -> None` + +Inherited from `Store`. + +### `write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None` + +Persist an attestation bundle. + +- **`bundle_id`**: Stable, filesystem-safe identifier that correlates the bundle with the audit run that produced it. Recommended shape: `---`. Backends MAY encode this into their storage key however they want (path segment, object key, primary key); the framework does not care. +- **`bundle_bytes`**: The serialized attestation. Framework provides the exact bytes it would have written to disk under the filesystem default. +- **`content_type`**: Media type of the bundle. Common values: `"application/vnd.in-toto+json"` for in-toto Statement v1, `"application/vnd.dev.sigstore.bundle+json"` for Sigstore bundles. +- **Raises**: `StoreOperationError` on any backend failure. Framework surfaces the error as an audit-run failure; the attestation is NOT reported as persisted. +- **Atomicity**: Per-call atomic (write commits or does not; no partial state). +- **Concurrency**: Sync in v0. Single-caller assumed within an audit run. + +## Failure semantics + +Per FR-011: + +| Failure | Consequence | +|---------|-------------| +| `write` raises | Audit-run error surfaced with the store's error message. Attestation is NOT reported as persisted. | +| `close()` raises | Logged; framework does NOT re-raise. | + +Rationale for stricter-than-cache semantics: an attestation that quietly failed to persist is a compliance record that quietly didn't happen. Constitution II demands the error be visible. + +## Consumers + +- `packages/darnit-baseline/src/darnit_baseline/attestation/generator.py::generate_attestation_from_results` -- sole call site today; the hard-coded `open(output_path, 'w')` at line 138 becomes `attestation_store.write(bundle_id, bundle_bytes, content_type)`. +- Future callers who write attestations (a fleet MCP server that batches audits, a re-attestation tool) should also consume through this Protocol. + +## Filesystem default + +`darnit.stores.defaults.attestation.FilesystemAttestationStore(root: Path)`: + +- Writes each bundle to `/.` where `` is derived from `content_type` (e.g., `.intoto.json` for in-toto, `.sigstore.json` for Sigstore). +- `root` defaults to `.darnit/attestations/`. +- `close()` is a no-op. + +## Non-goals for v0 + +- Read-back / enumeration of stored bundles (add `list_bundles()` as an additive Protocol extension if needed). +- Delete operations. +- Cross-bundle atomicity (each `write()` is independent). +- Signature validation (that's the downstream verifier's job; the store is a dumb persistence surface). diff --git a/specs/033-pluggable-stores/contracts/audit-cache-store.md b/specs/033-pluggable-stores/contracts/audit-cache-store.md new file mode 100644 index 00000000..38e23c2c --- /dev/null +++ b/specs/033-pluggable-stores/contracts/audit-cache-store.md @@ -0,0 +1,68 @@ +# Contract: `AuditCacheStore` Protocol + +**Owner**: `packages/darnit/src/darnit/stores/protocols.py` + +**Registered under**: `darnit.stores.cache` entry-point group + +**Stability**: Public API. v0 methods are stable; additions non-breaking. + +## Purpose + +Persist the per-audit-run cache. Cache lets remediation runs skip re-executing the audit when the cache is fresh. Existing TTL logic (`darnit.core.audit_cache`) stays in the caller; the Protocol is a dumb KV. + +## TOML surface + +```toml +[stores.cache] +backend = "redis" +url = "$REDIS_URL" +ttl_seconds = 3600 +``` + +## Methods + +### `close(self) -> None` + +Inherited from `Store`. + +### `read(self, cache_key: str) -> dict | None` + +Load a cache envelope. + +- **`cache_key`**: Opaque string the caller chose. Framework recommends `---` shape but does not enforce. +- **Returns**: The `dict` envelope written by a previous `write` call, or `None` on cache miss OR on any backend failure. +- **Raises**: MUST NOT raise. Backend failures during `read` MUST be swallowed and treated as cache miss. + +### `write(self, cache_key: str, envelope: dict) -> None` + +Persist a cache envelope. + +- **`envelope`**: The `dict` to store. Framework guarantees JSON-serializable values. +- **Raises**: MUST NOT raise. Backend failures during `write` MUST be logged and swallowed. The audit continues; next-run cache miss is acceptable. + +## Failure semantics + +Per FR-011: `AuditCacheStore` is the "best-effort" Protocol. Both `read` and `write` MUST NOT raise. This differs from `ProjectStateStore` and `AttestationStore`, which surface failures explicitly. + +Rationale: cache is a performance optimization, not a correctness requirement. A failing cache write leads to an extra audit run; that's a slowdown, not a compliance error. Constitution II demands loud failure on correctness errors, but cache failures are not correctness errors. + +## Consumers + +- `packages/darnit/src/darnit/core/audit_cache.py` -- `read_audit_cache` / `write_audit_cache` become thin wrappers over `store.read` / `store.write`. TTL check (envelope timestamp comparison) stays in the wrapper. +- `packages/darnit-baseline/src/darnit_baseline/remediation/orchestrator.py` -- reads the cache to skip re-audits. + +## Filesystem default + +`darnit.stores.defaults.cache.FilesystemAuditCacheStore(root: Path)`: + +- Reads from `/.json`. +- Writes via tempfile-then-rename for atomicity (existing behavior in `core/audit_cache.py:130-150` moves here). +- `close()` is a no-op. +- `root` defaults to `.darnit/audit-cache/`. + +## Non-goals for v0 + +- TTL logic in the store (stays in the caller). +- Enumeration of cached keys. +- Explicit invalidation / delete (callers write over the key or wait for TTL). +- Cross-key atomicity. diff --git a/specs/033-pluggable-stores/contracts/project-state-store.md b/specs/033-pluggable-stores/contracts/project-state-store.md new file mode 100644 index 00000000..b6f42f15 --- /dev/null +++ b/specs/033-pluggable-stores/contracts/project-state-store.md @@ -0,0 +1,85 @@ +# Contract: `ProjectStateStore` Protocol + +**Owner**: `packages/darnit/src/darnit/stores/protocols.py` + +**Registered under**: `darnit.stores.project` entry-point group + +**Stability**: Public API. v0 methods are stable; additions are non-breaking. Removals or signature changes require a major-version bump. + +## Purpose + +Persist the darnit-consumed subset of the CNCF `.project/` specification: `project.yaml`, `maintainers.yaml`, and the extensions bag. + +## TOML surface + +```toml +[stores.project] +backend = "postgres" +dsn = "$PG_DSN" +``` + +## Methods + +### `close(self) -> None` + +Inherited from `Store`. See the base contract at [`../data-model.md`](../data-model.md#store-protocol-base-runtime_checkable-in-darnitstoresprotocols). + +### `read_project(self) -> ProjectConfig | None` + +Load the project configuration. + +- **Returns**: `ProjectConfig` instance (see `darnit.context.dot_project.ProjectConfig`), or `None` if no project is stored under this backend's addressing. +- **Raises**: `StoreOperationError` on any backend failure that is not "not found" (e.g., malformed data, transient network failure, permission error). +- **Concurrency**: Sync in v0. Single-caller assumed within an audit run. + +### `write_project(self, config: ProjectConfig) -> None` + +Persist the project configuration. + +- **Preconditions**: `config` is a valid `ProjectConfig` (validated by the caller via Pydantic). +- **Raises**: `StoreOperationError` on backend failure. +- **Atomicity**: Per-call atomic (the write either commits or does not; no partial state). Cross-method atomicity (e.g., "write_project then write_maintainers together") is NOT guaranteed by this Protocol. + +### `read_maintainers(self) -> list[MaintainerEntry]` + +Load the maintainer entries. + +- **Returns**: List of `MaintainerEntry` instances. Empty list if none present. +- **Raises**: `StoreOperationError` on backend failure that is not "not found". + +### `write_maintainers(self, entries: list[MaintainerEntry]) -> None` + +Persist the maintainer entries. Same atomicity rules as `write_project`. + +## Failure semantics + +Per FR-011: + +| Failure | Consequence | +|---------|-------------| +| `read_project` raises | Caller (`darnit.tools.audit.load_project_context`) resolves affected controls WARN with message identifying the store and the failure reason. Never silent PASS. | +| `write_project` raises | Audit run fails with the store's error. State is not partially updated. | +| `read_maintainers` raises | Same as `read_project`. | +| `write_maintainers` raises | Same as `write_project`. | +| `close()` raises | Logged; framework does NOT re-raise. | + +## Consumers + +- `packages/darnit/src/darnit/context/dot_project.py` -- `DotProjectReader` and `DotProjectWriter` are the primary consumers. +- `packages/darnit/src/darnit/context/dot_project_org.py` -- org-fetched YAML flows through `write_project` / `write_maintainers`. +- `packages/darnit/src/darnit/tools/audit.py` -- injects the store at ExecutionContext construction. + +## Filesystem default + +`darnit.stores.defaults.project.FilesystemProjectStateStore(repo_path: Path)`: + +- Reads from `/.project/project.yaml` and `/.project/maintainers.yaml`. +- Writes to the same paths. +- `close()` is a no-op. +- Reproduces the pre-feature on-disk layout exactly (SC-003). + +## Non-goals for v0 + +- Read-back of extensions (v0 exposes only `project` and `maintainers`). +- Cross-artifact transactional semantics (e.g., "write project and maintainers atomically"). +- Delete operations (project state is written; unwritten keys have no explicit "delete" API). diff --git a/specs/033-pluggable-stores/contracts/report-store.md b/specs/033-pluggable-stores/contracts/report-store.md new file mode 100644 index 00000000..3c4c04d9 --- /dev/null +++ b/specs/033-pluggable-stores/contracts/report-store.md @@ -0,0 +1,74 @@ +# Contract: `ReportStore` Protocol + +**Owner**: `packages/darnit/src/darnit/stores/protocols.py` + +**Registered under**: `darnit.stores.report` entry-point group + +**Stability**: Public API. The three write methods are stable. Additional formats are additive Protocol extensions. + +## Purpose + +Persist audit reports in the three supported formats. In v0, this Protocol has no in-tree consumer (see #341); it exists so downstream features that write reports (starting with #341's CLI SARIF/Markdown emit) can consume through the Protocol without having to introduce the abstraction retroactively. + +## TOML surface + +```toml +[stores.report] +backend = "s3" +bucket = "my-fleet-reports" +region = "us-east-1" +prefix = "audits/$AUDIT_DATE/" +``` + +## Methods + +### `close(self) -> None` + +Inherited from `Store`. + +### `write_markdown(self, report_id: str, content: str) -> None` + +Persist a Markdown-formatted audit report. + +- **`report_id`**: Stable identifier correlating the report with the audit run. +- **`content`**: The full Markdown text. +- **Raises**: `StoreOperationError` on backend failure. +- **Atomicity**: Per-call atomic. +- **Concurrency**: Sync in v0. + +### `write_json(self, report_id: str, content: str) -> None` + +Persist a JSON audit report. Same shape as `write_markdown`; the content is a JSON string. + +### `write_sarif(self, report_id: str, content: str) -> None` + +Persist a SARIF-formatted audit report. Same shape as above; the content is a SARIF JSON string. + +## Failure semantics + +Per FR-011: + +| Failure | Consequence | +|---------|-------------| +| `write_*` raises | Audit-run error with the store's error and the format name (so the operator knows which of three writes failed). | +| `close()` raises | Logged; framework does NOT re-raise. | + +## Consumers + +- v0: none in the darnit tree. Filesystem default exists to enable #341 (CLI SARIF/Markdown emit) to write through the Protocol. +- Future callers should route report persistence through this Protocol rather than direct file I/O. + +## Filesystem default + +`darnit.stores.defaults.report.FilesystemReportStore(root: Path)`: + +- Writes to `/.md`, `/.json`, `/.sarif` respectively. +- `close()` is a no-op. +- If `root` does not exist at write time, creates it (like the pre-feature behavior of `open(path, "w")` in a made-up directory would fail). + +## Non-goals for v0 + +- Format registration (three formats, always these three, per Protocol design decision). +- Read-back of reports. +- Cross-format atomicity (each write independent). +- Format conversion (that's the formatter's job; the store persists what it's given). diff --git a/specs/033-pluggable-stores/data-model.md b/specs/033-pluggable-stores/data-model.md new file mode 100644 index 00000000..9db7f919 --- /dev/null +++ b/specs/033-pluggable-stores/data-model.md @@ -0,0 +1,240 @@ +# Phase 1 Data Model: Pluggable stores + +## Purpose + +Enumerate every new type this feature introduces, its fields, its constraints, and its lifecycle. This is the vocabulary the plan phase locks in for the reader contract, the tasks decomposition, and future reconciliation-style diffs. + +## New types (public API) + +### `Store` (Protocol base, `runtime_checkable`) in `darnit.stores.protocols` + +The shared close-contract that every artifact-class Protocol inherits. + +```python +@runtime_checkable +class Store(Protocol): + def close(self) -> None: + """Release any resources held by this store. + + MUST be idempotent (a second call is a no-op). + MUST NOT raise on the "already closed" case. + MAY raise on unrecoverable teardown failure (network partition, + disk full during flush). Callers wrap the call in try/except and + log; they do NOT re-raise. + + The framework calls close() exactly once at audit-boundary + tear-down, regardless of success or exception in the audit path + (see `darnit.tools.audit._run_audit`'s finally block). + """ + ... +``` + +Not intended to be used as a standalone Protocol -- it exists to consolidate the FR-019 `close()` contract in one place. Every subclass Protocol inherits from it. + +### `ProjectStateStore` (Protocol, `runtime_checkable`) + +Read + write surface for `.project/project.yaml`, `.project/maintainers.yaml`, extensions. + +```python +@runtime_checkable +class ProjectStateStore(Store, Protocol): + def read_project(self) -> ProjectConfig | None: + """Load the project configuration. Returns None if not present. + Raises `StoreOperationError` on backend failure.""" + + def write_project(self, config: ProjectConfig) -> None: + """Persist the project configuration. + Raises `StoreOperationError` on backend failure.""" + + def read_maintainers(self) -> list[MaintainerEntry]: + """Load the maintainer entries. Returns [] if not present.""" + + def write_maintainers(self, entries: list[MaintainerEntry]) -> None: + """Persist the maintainer entries.""" +``` + +Failure semantics per FR-011: +- `read_project` failure -> caller resolves affected controls WARN (see `darnit.tools.audit.load_project_context`). +- `write_project` / `write_maintainers` failure -> caller surfaces the error to the operator; the audit run fails. + +### `AttestationStore` (Protocol, `runtime_checkable`) + +Write-only surface for attestation bundles. + +```python +@runtime_checkable +class AttestationStore(Store, Protocol): + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + """Persist an attestation bundle. + + `bundle_id` is a stable identifier the operator can use to + correlate the bundle with the audit run that produced it (e.g., + -). Must be filesystem-safe. + `content_type` names the media type (e.g., "application/vnd.in-toto+json" + for in-toto statements, "application/vnd.dev.sigstore.bundle+json" + for Sigstore bundles). + Raises `StoreOperationError` on backend failure. + """ +``` + +Read-back is intentionally NOT in v0: attestations are consumed downstream by other tooling (Sigstore, in-toto verifiers), not by darnit itself. If darnit ever needs to enumerate its own attestations, add a `list_bundles()` method as an additive Protocol extension. + +### `ReportStore` (Protocol, `runtime_checkable`) + +Write surface for audit reports in the three supported formats. + +```python +@runtime_checkable +class ReportStore(Store, Protocol): + def write_markdown(self, report_id: str, content: str) -> None: ... + def write_json(self, report_id: str, content: str) -> None: ... + def write_sarif(self, report_id: str, content: str) -> None: ... +``` + +Format-specific methods (rather than a generic `write(format, content)`) so mypy/pyright can enforce the "three formats, always these three" invariant at the type layer. Adding a fourth format is an additive Protocol change. + +Note: v0 has no existing report-writing call site to migrate. The Protocol + filesystem default exist to enable follow-up features (starting with #341) to write through the Protocol without having to introduce the abstraction retroactively. + +### `AuditCacheStore` (Protocol, `runtime_checkable`) + +Read + write surface for the per-audit-run cache. + +```python +@runtime_checkable +class AuditCacheStore(Store, Protocol): + def read(self, cache_key: str) -> dict | None: + """Load a cache envelope. Returns None on miss or on any backend failure.""" + + def write(self, cache_key: str, envelope: dict) -> None: + """Persist a cache envelope. Best-effort: backend failures MUST NOT + raise; caller logs and continues.""" +``` + +Failure semantics per FR-011: cache is best-effort. A failing `write` is logged and the audit run continues (next-run cache miss is acceptable). A failing `read` returns `None` (cache miss). Existing TTL semantics (from `core/audit_cache.py`) stay in the caller; the store is a dumb KV. + +## New types (config-schema) + +### `StoreBlock` (Pydantic model) in `darnit.config.framework_schema` + +One `[stores.]` TOML block. + +| Field | Type | Default | Constraint | +|-------|------|---------|------------| +| `backend` | `str` | required | Must match a registered entry point under `darnit.stores.` (validated at selection time, not schema time; discovery is a runtime concern). | +| (extras) | `str \| int \| bool \| list \| dict` | -- | Passed through to the backend's `__init__` as kwargs. String values are passed through `darnit.core.env_subst.substitute_dollar_vars(value)` at load time. | + +```python +class StoreBlock(BaseModel): + backend: str + model_config = ConfigDict(extra="allow") +``` + +### `StoresConfig` (Pydantic model) + +The four artifact-class-keyed store blocks. + +| Field | Type | Default | Constraint | +|-------|------|---------|------------| +| `project` | `StoreBlock \| None` | `None` | -- | +| `attestation` | `StoreBlock \| None` | `None` | -- | +| `report` | `StoreBlock \| None` | `None` | -- | +| `cache` | `StoreBlock \| None` | `None` | -- | + +`model_config = ConfigDict(extra="forbid")` -- catches typos like `[stores.audit_log]` at schema-load time. + +```python +class StoresConfig(BaseModel): + project: StoreBlock | None = None + attestation: StoreBlock | None = None + report: StoreBlock | None = None + cache: StoreBlock | None = None + model_config = ConfigDict(extra="forbid") +``` + +## New types (runtime-only, not public API) + +### `_StoreBundle` (dataclass) in `darnit.stores.selection` + +Runtime holder for the four resolved store instances of a single audit run. + +```python +@dataclass +class _StoreBundle: + project: ProjectStateStore + attestation: AttestationStore + report: ReportStore + cache: AuditCacheStore + + def close_all(self) -> None: + """Call close() on every store. Idempotent; safe to call multiple + times (each store's close() is required to be idempotent per FR-019). + Exceptions during one store's close() are logged and swallowed so + a failure in one does not prevent the others from being closed.""" +``` + +## Existing types touched + +### `FrameworkConfig` (in `framework_schema.py`) + +Add: + +```python +stores: StoresConfig = Field(default_factory=StoresConfig) +``` + +Placed alongside `plugins` and `mcp_servers` so the three extension surfaces sit together in the schema. + +### `UserConfig` (in `user_schema.py`) + +Mirror: + +```python +stores: StoresConfig = Field(default_factory=StoresConfig) +``` + +### `merge_configs()` (in `merger.py`) + +Add per-kind replacement for each of the four store blocks. `.baseline.toml`'s `[stores.]` for a given kind fully replaces the framework TOML block for that kind; disjoint kinds coexist. Pseudocode: + +```python +for kind in ("project", "attestation", "report", "cache"): + if getattr(user.stores, kind) is not None: + setattr(framework.stores, kind, getattr(user.stores, kind)) +``` + +Mirrors the existing `mcp_servers` merger from feature 031. + +### `darnit.core.env_subst.substitute_dollar_vars` (new, extracted per research R-004) + +Public helper. Two existing call sites migrate to it: feature 025's `exec_handler` and feature 031's `mcp_pool._substitute_env`. Regression tests assert identical behavior on both call sites. + +## Constants introduced + +- `STORE_ENTRY_POINT_GROUPS = ("darnit.stores.project", "darnit.stores.attestation", "darnit.stores.report", "darnit.stores.cache")` in `darnit.stores.discovery`. +- `_STORE_KINDS = ("project", "attestation", "report", "cache")` in `darnit.stores.selection` (used for iterating the four artifact classes). + +## State transitions + +### Store lifecycle + +``` +[ DISCOVERED ] -- selection --> [ INSTANTIATED ] -- audit runs --> [ CLOSED ] + ^ | + | v + +--- next process starts ----------------------------- [ GARBAGE COLLECTED ] +``` + +Discovery happens once per process at framework-load time (FR-005). Instantiation happens lazily on first use per artifact class within an audit run (FR-010). Close happens exactly once at audit-boundary tear-down (FR-019). + +### Failure semantics per Protocol + +| Protocol | Read failure | Write failure | +|----------|--------------|---------------| +| `ProjectStateStore` | Caller resolves affected controls WARN. Never silent PASS. | Surface as audit-run error; abort the run. | +| `AttestationStore` | n/a (write-only) | Surface as audit-run error; the attestation is NOT reported as persisted. | +| `ReportStore` | n/a (write-only) | Surface as audit-run error with format name. | +| `AuditCacheStore` | Return `None` (cache miss); log warning. | Log warning; audit continues. Best-effort. | + +## Non-model concerns + +Everything else about this feature reuses machinery that already exists: TOML parsing (Pydantic), entry-point discovery (`importlib.metadata`, per feature 027's pattern), config merging (per feature 031's pattern), `try/finally` teardown (per feature 031's `verify_batch` pattern). No new pydantic models beyond `StoreBlock` and `StoresConfig`; no schema migrations; no persistent state changes beyond the four filesystem defaults reproducing the pre-feature on-disk layout. diff --git a/specs/033-pluggable-stores/plan.md b/specs/033-pluggable-stores/plan.md new file mode 100644 index 00000000..dba8ec52 --- /dev/null +++ b/specs/033-pluggable-stores/plan.md @@ -0,0 +1,275 @@ +# Implementation Plan: Pluggable storage backends via per-artifact Protocols + +**Branch**: `033-pluggable-stores` | **Date**: 2026-08-25 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/033-pluggable-stores/spec.md` (with 3 clarifications recorded 2026-08-25: `close()` teardown method required on every Protocol, `$VAR` substitution for secrets in `[stores.*]` blocks, entry-point discovery at framework-load time). + +## Summary + +Introduce a `darnit.stores` sub-package under `packages/darnit/` that defines four `typing.Protocol`s (`ProjectStateStore`, `AttestationStore`, `ReportStore`, `AuditCacheStore`), each with an explicit `close()` teardown method (FR-019). Ship a filesystem-backed default implementation for each Protocol that reproduces the pre-feature on-disk layout exactly (SC-003, User Story 2). Add `[stores.]` blocks to `FrameworkConfig` and `UserConfig` for backend selection (FR-006), with `$VAR` substitution for backend-specific string values reusing the pattern from features 025 (`exec` handler) and 031 (mcp `env` block). Discover third-party backend implementations exactly once per process at framework-load time via `importlib.metadata` entry points under `darnit.stores.project` / `.attestation` / `.report` / `.cache` (FR-005), matching feature 027's `QuestionResolver` discovery pattern. Rewrite the ~10 hard-coded filesystem call sites (attestation generator, report formatters, audit-cache reader/writer, `.project/` reader/writer) to consume the Protocol; store operations happen at audit-boundary composition, keeping the sieve pipeline's PASS/FAIL/WARN/ERROR contract intact (Constitution V, FR-017). + +Zero new runtime dependencies (`importlib.metadata`, `typing.Protocol`, and `runtime_checkable` are all standard library). Zero product-source additions outside `packages/darnit/` and its tests. First non-filesystem backend (Postgres for `ProjectStateStore`) lands as follow-up #391 once this feature is on `main`. + +## Technical Context + +**Language/Version**: Python 3.11/3.12 (workspace targets - unchanged). + +**Primary Dependencies**: `importlib.metadata` (standard library since Python 3.8, already used by feature 027 for `QuestionResolver` discovery); `typing.Protocol` + `@runtime_checkable` (standard library); Pydantic 2.x (already used for framework schema, adds the new `[stores.*]` block validation). No new pip dependencies. + +**Storage**: Filesystem only in v0. Every default implementation shipped by this feature is filesystem-backed and reproduces the pre-feature on-disk layout exactly. Non-filesystem backends are third-party plugin packages; #391 tracks the first one. + +**Testing**: pytest, extending existing test layers under `tests/darnit/stores/` (new directory) and touching `tests/darnit_baseline/` where the attestation-generator and report-formatter call sites move. Two categories of test-only fixtures ship with this feature: (a) an in-memory reference backend for each Protocol (under `packages/darnit-testchecks/src/darnit_testchecks/stores/`) that tests substitute for SC-002 equivalence tests; (b) a fixture plugin package (`tests/darnit/stores/fixtures/example_store_plugin_pkg/`) that lives outside the `darnit` namespace and proves at CI time that the entry-point discovery machinery works against a real installable Python package (SC-005, User Story 3). + +**Target Platform**: Any platform Python 3.11+ runs on. No platform-specific code paths introduced. + +**Project Type**: Library/framework internal change; scoped to `packages/darnit/` core plus its tests. No new packages, no plugin implementations built. + +**Performance Goals**: Not a hot path. Store operations happen at audit-boundary composition (once per audit run per artifact class, at most). Filesystem-default I/O cost is unchanged from pre-feature (same files, same paths). Entry-point discovery cost pays once at process start (SC-004 measures this via a spy). Additional lazy-instantiation branch adds one dict lookup + optional `importlib.metadata` fetch per audit run per artifact class -- microseconds. The performance-sensitive property is negative: FR-014 requires zero new pip dependencies; SC-001 requires zero regression on the existing 2815-test workspace. + +**Constraints**: +- Zero product-source additions outside `packages/darnit/` and its tests (FR-013). +- No new required arguments on any public callable that existing internal callers pass without modification (matches feature 030 / 032's discipline). +- Sync Protocols only in v0 (spec Assumptions -- async is an explicit non-goal). +- Every Protocol MUST expose `close()` (FR-019); framework MUST call it exactly once at audit-boundary tear-down. +- No silent fallback to the filesystem default when a selected backend fails (FR-012); fail-fast per FR-008. + +**Scale/Scope**: One new sub-package (`darnit.stores`) with ~8 modules (Protocols, discovery, selection, four filesystem defaults, errors). Two new TOML schema fields on `FrameworkConfig` and `UserConfig`. ~10 rewritten call sites (attestation generator, three report formatters, audit-cache reader + writer, `.project/` reader + writer, `.project/` org-fetch writer). Two test-fixture packages. Estimated diff: ~1200 lines of production code, ~1500 lines of test code (Protocol conformance tests + call-site integration tests + fixture plugin package + in-memory backends). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The darnit constitution (5 core principles, plus architecture constraints and workflow rules) evaluated against this feature: + +| Principle | Applies | Assessment | +|-----------|---------|------------| +| I. Plugin Separation | Yes | PASS. The `darnit.stores` sub-package and its filesystem defaults live in `packages/darnit/` core. Third-party backend implementations live in plugin packages, never imported by core. The entry-point discovery machinery imports plugin packages *only* via `importlib.metadata`, which lazily loads distributions at the point of selection; the discovery mechanism itself is import-cycle-free by construction. Explicit static-import guard (SC-008) proves darnit-core does not import any implementation package. | +| II. Conservative-by-Default | Yes | PASS. FR-011 spells out the WARN/ERROR/best-effort mapping per Protocol so a failing `ProjectStateStore` read resolves affected controls WARN (not silent PASS, not silent FAIL). FR-012 forbids silent fallback to the filesystem default when a selected backend fails; the operator's selection is honored to the point of failure. FR-008 requires fail-fast on unresolvable backend selection *before* any control runs, matching feature 019's "definitive verdicts always beat silent WARN" posture applied to the misconfiguration surface. | +| III. TOML-First Architecture | Yes | PASS. Backend selection is entirely a TOML surface (`[stores.] backend = "..."` under `.baseline.toml` or the framework TOML). Backend-specific config (dsn, region, etc.) is also TOML-native, with `$VAR` substitution for secrets that reuses features 025/031's existing pattern -- no new config mechanism plugin authors must learn. No Python-code escape hatch for backend selection is introduced. | +| IV. Never Guess User Values | Yes | PASS. This feature does not touch the auto-detect / user-judgment surface at all. Storage backend selection is a fleet-configuration choice, not a per-control judgment. `auto_detect` / `allow_sieve_hints` machinery is untouched. | +| V. Sieve Pipeline Integrity | Yes | PASS. FR-017 forbids modifying sieve handlers, remediation handlers, or MCP tools to consume the store abstraction directly. Store access happens exclusively at audit-boundary composition points (audit driver, remediation orchestrator, attestation generator, report formatters). The sieve orchestrator's PASS/FAIL/WARN/ERROR contract is unchanged; controls do not know stores exist. | + +Architecture constraints (three-layer architecture, package structure): PASS. Layer 1 (Checking / sieve handlers) is untouched. Layer 2 (Remediation) receives a small tweak at the audit-driver-facing composition point (writes go through the store instead of a hard-coded path). Layer 3 (MCP Tools) is unchanged. The `darnit.stores` sub-package sits alongside `darnit.sieve` and `darnit.config` -- horizontal to the three layers, not a new layer. + +Development workflow (lint, tests, spec sync, no-emoji rules): PASS. Standard workflow. The spec-sync check (`scripts/validate_sync.py`) validates handler names in code against `docs/architecture/framework-design.md`; this feature introduces no new handlers, so the sync check is untouched. One line will be added to Section 12 of `framework-design.md` naming the `darnit.stores` sub-package as a new extension surface, matching how features 027 and 031 documented their extension surfaces. + +**Gate result: PASS. Proceed to Phase 0.** + +## Project Structure + +### Documentation (this feature) + +```text +specs/033-pluggable-stores/ +├── plan.md # This file +├── research.md # Phase 0 output - call-site inventory, discovery pattern reuse, close() ownership, $VAR helper reuse +├── data-model.md # Phase 1 output - the four Protocols + config schema + selection contract +├── quickstart.md # Phase 1 output - operator + plugin-author worked examples +├── contracts/ +│ ├── project-state-store.md +│ ├── attestation-store.md +│ ├── report-store.md +│ └── audit-cache-store.md +├── checklists/ +│ └── requirements.md # From /speckit-specify (all 16 items pass) +└── tasks.md # /speckit-tasks output (not created here) +``` + +### Source Code (repository root) + +```text +packages/darnit/src/darnit/stores/ +├── __init__.py +├── protocols.py # NEW. The four typing.Protocol classes + Store base + close() surface. +├── discovery.py # NEW. importlib.metadata entry-point discovery under `darnit.stores.` +│ # groups. Runs once at framework-load; per-process cache. +├── selection.py # NEW. Resolves `[stores.]` -> instantiated backend. Handles `$VAR` +│ # substitution for backend-specific keys. Emits fail-fast errors per FR-008. +├── errors.py # NEW. StoreError base + StoreNotInstalled, StoreProtocolMismatch, +│ # StoreNameCollision, StoreOperationError. +├── defaults/ # NEW. Filesystem-backed default implementations. +│ ├── __init__.py +│ ├── project.py # FilesystemProjectStateStore (reads/writes .project/) +│ ├── attestation.py # FilesystemAttestationStore (writes .darnit/attestations/) +│ ├── report.py # FilesystemReportStore (writes Markdown/JSON/SARIF to configured paths) +│ └── cache.py # FilesystemAuditCacheStore (reads/writes .darnit/audit-cache/) +└── env_subst.py # NEW. Shared $VAR substitution helper. Extracted from where feature 025's + # exec handler and feature 031's mcp env block currently duplicate it, so + # the same code runs in all three call sites (also fixes a latent bug where + # the two existing copies drifted apart on unset-var behavior; research R-004). + +packages/darnit/src/darnit/config/ +├── framework_schema.py # UPDATE. Add StoresConfig + StoreBlock Pydantic models; add +│ # `stores: StoresConfig` field to FrameworkConfig alongside plugins/mcp_servers. +└── user_schema.py # UPDATE. Mirror the same `stores` field on UserConfig. + +packages/darnit/src/darnit/config/merger.py + # UPDATE. Merge `stores` blocks with the same precedence rule other blocks + # use (.baseline.toml block for a given fully replaces the framework + # TOML block for that ). One-line addition alongside the mcp_servers + # merger from feature 031. + +packages/darnit/src/darnit/tools/audit.py + # UPDATE. At the ExecutionContext construction site (line ~425), pass the + # store bundle. Introduce `_stores_bundle_for_run` helper that resolves all + # four Protocols lazily. Wrap the per-audit-run loop in a try/finally that + # calls close() on every instantiated store, matching feature 031's mcp + # pool teardown pattern. + +packages/darnit-baseline/src/darnit_baseline/attestation/generator.py + # UPDATE. Replace hard-coded `open(output_path, 'w')` call at line 138 with + # store.write(attestation_bundle). Existing output-path argument becomes an + # AttestationStore configured with that path. + +packages/darnit-baseline/src/darnit_baseline/formatters/ (three formatters) + # UPDATE. Each formatter's file-write goes through ReportStore.write(). + +packages/darnit/src/darnit/core/audit_cache.py + # UPDATE. read_audit_cache / write_audit_cache become thin wrappers over + # AuditCacheStore.read / .write. Existing atomic-rename semantics move + # into the FilesystemAuditCacheStore default implementation. + +packages/darnit/src/darnit/context/dot_project.py + # UPDATE. DotProjectReader / DotProjectWriter now consume a ProjectStateStore. + # The audit driver passes the store; existing .project/ path arguments become + # the FilesystemProjectStateStore's on-disk root. + +packages/darnit-testchecks/src/darnit_testchecks/stores/ + # NEW. In-memory reference implementations for each Protocol. Consumed by + # SC-002's fixture-equivalence tests. Not shipped as a runtime dependency + # of darnit-core; darnit-testchecks is dev-only. +├── __init__.py +├── in_memory_project.py +├── in_memory_attestation.py +├── in_memory_report.py +└── in_memory_cache.py + +tests/darnit/stores/ +├── test_protocols.py # NEW. runtime_checkable + close() contract per Protocol. +├── test_discovery.py # NEW. entry-point discovery: happy path, name collision (FR-009), +│ # missing plugin (FR-008), Protocol mismatch (FR-002). +├── test_selection.py # NEW. TOML -> store: $VAR substitution, backend-specific kwargs, +│ # fail-fast on uninstalled / non-conformant / colliding backends. +├── test_env_subst.py # NEW. $VAR helper unit tests, plus a regression test asserting +│ # feature 025 (exec) and feature 031 (mcp) still produce identical +│ # substitution behavior after the extraction. +├── test_filesystem_defaults.py # NEW. Round-trip tests for each of the four filesystem defaults. +├── test_lazy_instantiation.py # NEW. SC-004: an audit that produces no attestations does not +│ # instantiate an AttestationStore. +├── test_audit_boundary_close.py # NEW. SC-006 + FR-019: close() called exactly once per instantiated +│ # store, on every exit path (success, control failure, exception). +└── fixtures/ + └── example_store_plugin_pkg/ # NEW. A minimal installable Python package that registers a no-op + │ # backend under darnit.stores.attestation. Consumed by SC-005 / + │ # User Story 3 discovery-through-a-real-entry-point test. + ├── pyproject.toml + ├── README.md + └── src/example_store_plugin/ + ├── __init__.py + └── backend.py + +tests/darnit_baseline/attestation/ (updates) + # Existing attestation-generator tests get one-line updates to inject the + # in-memory AttestationStore. No new tests unless a call-site regression + # surfaces. + +tests/darnit_baseline/formatters/ (updates) + # Existing formatter tests get the same shape as above for ReportStore. + +tests/darnit/core/test_audit_cache.py (updates) + # Existing audit-cache tests get the in-memory backend swap. + +tests/darnit/context/ (updates) + # Existing dot-project reader/writer tests get the in-memory backend swap. + # This is the biggest test-side change footprint; expected ~50 test updates + # across the .project/ suite. + +docs/architecture/framework-design.md + # One-line addition to Section 12 naming `darnit.stores` as the persistence + # extension surface, alongside `darnit.frameworks` and + # `darnit.question_resolvers`. +``` + +**Structure Decision**: Introduce `darnit.stores` as a sibling sub-package under `packages/darnit/src/darnit/`, horizontal to `darnit.sieve` and `darnit.config`. The four Protocols live in a single module (`protocols.py`) because their contracts are read together and independently changed rarely; splitting per Protocol would fragment the reader contract. Filesystem defaults live under `darnit.stores.defaults` (not scattered across `attestation/`, `context/`, etc.) so a plugin author can find the entire reference implementation in one place. The `$VAR` substitution helper is extracted from where it's currently duplicated in features 025 and 031 into `darnit.stores.env_subst` (accepting the mild irony that a "stores" module owns a helper both handlers use; the alternative is a `darnit.core.env_subst` which is also fine -- research R-004 evaluates the placement). Test-only in-memory backends live in `darnit-testchecks` so they never appear in the runtime install; the fixture plugin package lives under `tests/` because it exists exclusively to prove discovery works and is not something an end user would install. + +## Complexity Tracking + +No constitution violations to justify. This section is intentionally short. + +## Phase 0: Research + +Research questions surfaced by Technical Context and the spec's Assumptions/Edge Cases: + +1. **R-001: Enumerate the current hard-coded filesystem call sites per artifact class.** The plan's "Source Code" tree lists ~10 rewrites; that count is from a first read. Phase 0 grep-audit produces the authoritative list and its groupings so the tasks decomposition knows exactly what to touch. Decision: run the enumeration and record it in research.md as a table `{artifact-class, module:line, current-shape}`. Alternatives considered: defer to tasks phase (rejected -- the tasks phase needs the count locked to write per-call-site tasks). + +2. **R-002: `close()` teardown ownership at the audit-boundary.** Every instantiated store must be closed exactly once on every exit path (success, control failure, exception). Feature 031's `SieveOrchestrator.verify_batch` uses a try/finally around the per-control loop for its MCP-pool teardown; the same pattern applies here, but at a slightly wider boundary because reports and attestations are written by post-loop composition, not by the sieve itself. Decision: introduce `_StoreBundle` in `darnit.stores.selection` that owns all instantiated stores for the run; `darnit.tools.audit._run_audit` wraps the whole audit-scoped block in a try/finally that calls `bundle.close_all()`. Alternatives considered: (a) per-Protocol context managers threaded through every consumer (rejected -- fragments teardown across N call sites); (b) `atexit` handlers on individual stores (rejected -- fires too late, misses the "close at audit boundary" contract). + +3. **R-003: `importlib.metadata` entry-point discovery pattern (reuse from feature 027).** Feature 027's `resolver_discovery.py` uses `importlib.metadata.entry_points(group="darnit.question_resolvers")` and lazy-loads each entry point at discovery time, wrapping ImportError / other exceptions per entry point so a broken plugin does not blank the whole discovery result. Decision: copy that shape into `darnit.stores.discovery.discover_stores(group)`, adapt for the four groups, add name-collision detection (FR-009). Alternatives considered: (a) roll a fresh pattern (rejected -- codebase should have one entry-point discovery convention); (b) share a helper module between the two features (rejected for v0 -- factor after the third consumer, per YAGNI). + +4. **R-004: `$VAR` substitution helper -- extract or duplicate.** Features 025 (`exec` handler) and 031 (mcp `env` block) each carry a `$VAR` substitution routine. Reading both: they differ subtly on the "unset variable" case (031 substitutes empty string; 025 substitutes empty string BUT logs a debug line; both use the same regex). Decision: extract to `darnit.stores.env_subst.substitute_dollar_vars(template, env, *, missing_ok=True)`. Both existing call sites migrate to it. Regression test asserts identical behavior on the previous inputs to both features. Alternatives considered: (a) leave the two copies alone and add a third for stores (rejected -- three copies is worse than two); (b) move to a `darnit.core` module (considered -- `darnit.stores` slightly awkward, `darnit.core.env_subst` would be more natural; deferred to tasks phase, low-stakes). + +5. **R-005: Config schema addition (StoresConfig / StoreBlock).** Feature 031 added `mcp_servers: dict[str, McpServerConfig]` on `FrameworkConfig` with `extra="forbid"` on the block model (its own T005 lesson). Decision: mirror exactly. `StoreBlock(BaseModel)` with `backend: str` required and `extra="allow"` so backend-specific keys pass through to the backend's `__init__`. `StoresConfig` groups the four artifact-class-keyed blocks (`project`, `attestation`, `report`, `cache`). Merger adds one line alongside the `mcp_servers` merger (T007 of feature 031). Alternatives considered: (a) one flat `stores: dict[str, StoreBlock]` at the top level (rejected -- the fixed set of four artifact classes is a schema invariant, not a runtime one); (b) require an explicit `backend = "filesystem"` in every default TOML (rejected -- undermines User Story 2 zero-config path). + +6. **R-006: In-memory reference backend placement.** Test-only backends could live under `tests/` or under `packages/darnit-testchecks/`. Decision: place under `darnit-testchecks` because that package's charter (matches the sieve's `testchecks` implementation) is exactly "reference implementations useful for testing." They are importable from any test suite; they are not shipped in the runtime install of darnit-core. Alternatives considered: (a) place under `tests/darnit/stores/fixtures/` (rejected -- makes cross-test reuse awkward, requires PYTHONPATH manipulation); (b) place under a fresh `darnit-testkit` package (rejected -- new package cost for what fits in existing `darnit-testchecks`). + +7. **R-007: Fixture plugin package registration.** The example plugin package under `tests/darnit/stores/fixtures/example_store_plugin_pkg/` must be `pip install`-able so its entry point actually registers. The CI job that runs the discovery tests must `pip install -e tests/darnit/stores/fixtures/example_store_plugin_pkg/` before running. Decision: add a session-scoped pytest fixture that installs it (using `subprocess` + `pip install -e`) at the start of the discovery test session, and uninstalls at the end. Alternatives considered: (a) statically add the fixture package to the workspace `pyproject.toml` (rejected -- pollutes the runtime environment for every test, not just discovery tests); (b) use `sys.path` gymnastics to fake an entry point (rejected -- feature 027 rejected this same shortcut for the same reason: the whole point is proving discovery works against a REAL entry point). + +**Output**: `research.md` with each decision + rationale + rejected alternatives per the template. + +## Phase 1: Design & Contracts + +**Prerequisites**: `research.md` complete. + +### Data Model (`data-model.md`) + +New schema types: + +- **`Store` (Protocol base, `runtime_checkable`)** in `darnit.stores.protocols`. Defines the `close(self) -> None` method every subclass Protocol inherits. Not intended to be used as a standalone Protocol -- it exists to consolidate the FR-019 `close()` contract in one place. +- **`ProjectStateStore` (Protocol, `runtime_checkable`)**. Reader + writer surface for `.project/project.yaml`, `.project/maintainers.yaml`, extensions. Methods: `read_project() -> ProjectConfig | None`, `write_project(config: ProjectConfig) -> None`, `read_maintainers() -> list[MaintainerEntry]`, `write_maintainers(entries: list[MaintainerEntry]) -> None`. Inherits `close()` from `Store`. +- **`AttestationStore` (Protocol, `runtime_checkable`)**. Write-only surface for attestation bundles. Methods: `write(bundle_id: str, bundle_bytes: bytes, content_type: str) -> None`, `close() -> None`. Read-back is intentionally NOT in v0: attestations are consumed downstream by other tooling (Sigstore, in-toto verifiers) not by darnit itself. If darnit ever needs to enumerate its own attestations, add a `list_bundles()` method as an additive Protocol extension. +- **`ReportStore` (Protocol, `runtime_checkable`)**. Write surface for audit reports in the three supported formats. Methods: `write_markdown(report_id: str, content: str) -> None`, `write_json(report_id: str, content: str) -> None`, `write_sarif(report_id: str, content: str) -> None`. Format-specific methods (rather than a generic `write(format, content)`) so the Protocol makes the invariant "three formats, always these three" enforceable by mypy/pyright. +- **`AuditCacheStore` (Protocol, `runtime_checkable`)**. Read + write surface for the per-audit-run cache. Methods: `read(cache_key: str) -> dict | None`, `write(cache_key: str, envelope: dict) -> None`. Existing TTL semantics (from `core/audit_cache.py`) stay in the caller; the store is a dumb read-through/write-through KV. +- **`StoresConfig`** (Pydantic model in `framework_schema.py`). Fixed keys `project`, `attestation`, `report`, `cache`, each optional and each a `StoreBlock`. +- **`StoreBlock`** (Pydantic model). `backend: str` required. `extra="allow"` so backend-specific keys pass through to the backend's `__init__`. String values are passed through `substitute_dollar_vars` at load time. +- **`_StoreBundle`** (runtime-only dataclass in `selection.py`). Holds the four resolved store instances plus a `close_all()` method that calls `close()` on every store that was actually instantiated. Owned by `darnit.tools.audit._run_audit`'s try/finally. + +Existing types touched: + +- `FrameworkConfig.stores: StoresConfig` added alongside `plugins` and `mcp_servers`. +- `UserConfig.stores: StoresConfig` added alongside `mcp_servers`. +- `merger.py` per-name replacement rule for `stores.` (one line, mirroring the `mcp_servers` merger). + +### Contracts (`contracts/`) + +Four files, one per Protocol: + +- `contracts/project-state-store.md` +- `contracts/attestation-store.md` +- `contracts/report-store.md` +- `contracts/audit-cache-store.md` + +Each contract covers: method signatures, contracts on inputs (raises what, when), concurrency model (sync in v0, single-caller), transactional guarantees (per-write, no cross-method atomicity), close-idempotence + close-safety, and the specific WARN/ERROR/best-effort mapping FR-011 defines for that Protocol's failure modes. Contracts are the file plugin authors read first. + +### Quickstart (`quickstart.md`) + +Three worked examples: + +1. **Operator selects a backend.** Add three lines to `.baseline.toml`. Verify with `darnit audit` that the store's `write()` was called (via the backend's own diagnostics). +2. **Plugin author distributes a backend.** Author a minimal `AttestationStore` implementation in a new Python package, register the entry point, `pip install -e .`, verify discovery via `python -c "from darnit.stores.discovery import discover_stores; print(discover_stores('darnit.stores.attestation'))"`. +3. **Backing out.** Remove the `[stores.attestation]` block from `.baseline.toml`. Confirm the framework reverts to the filesystem default and reads/writes on the same paths as pre-feature. + +### Agent Context Update + +Update the reference between `` and `` markers in `CLAUDE.md` to point at `specs/033-pluggable-stores/plan.md`. + +## Post-Design Constitution Recheck + +The design phase artifacts do not introduce any new principle-touching decisions: + +- **I. Plugin Separation**: reinforced by the module layout -- `darnit.stores` sub-package under `packages/darnit/src/darnit/`, no imports of implementation packages. The static-import guard (SC-008) is the mechanical enforcement. +- **II. Conservative-by-Default**: reinforced by FR-011's per-Protocol failure-mode mapping (`ProjectStateStore` -> WARN, `AttestationStore` -> ERROR, `AuditCacheStore` -> best-effort, `ReportStore` -> ERROR-with-note); no silent fallback (FR-012). +- **III. TOML-First**: reinforced by the `StoresConfig` schema addition + the `$VAR` substitution helper being TOML-native. +- **IV. Never Guess User Values**: not touched; storage backend selection is a fleet-config choice, not a per-control judgment. +- **V. Sieve Pipeline Integrity**: reinforced by the FR-017 constraint that no sieve/remediation/MCP handler consumes stores directly; the audit-boundary composition points are the only touchpoints, and the sieve's PASS/FAIL/WARN/ERROR contract is unchanged. + +**Post-design gate: PASS.** diff --git a/specs/033-pluggable-stores/quickstart.md b/specs/033-pluggable-stores/quickstart.md new file mode 100644 index 00000000..2f5cd15c --- /dev/null +++ b/specs/033-pluggable-stores/quickstart.md @@ -0,0 +1,158 @@ +# Quickstart: pluggable stores + +Three worked examples: operator selects a backend, plugin author distributes one, operator backs out. + +## Example 1: Operator selects a Postgres backend for project state + +The operator's fleet already runs `darnit audit` against many repositories. They want project state (the CNCF `.project/` YAML tree) to live in a shared Postgres instance instead of being duplicated on-disk in every repo checkout. + +### Prerequisites + +- A published Python package that registers a `ProjectStateStore` implementation under `darnit.stores.project`. For this example, assume that package is called `darnit-store-postgres` (see #391 for the actual implementation once it lands). +- `pip install darnit-store-postgres` in the environment where `darnit audit` runs. + +### `.baseline.toml` + +```toml +extends = "openssf-baseline" + +[stores.project] +backend = "postgres" +dsn = "$PG_DSN" +schema = "darnit" +``` + +`$PG_DSN` gets substituted from `os.environ["PG_DSN"]` at load time; if it is unset, it substitutes as empty string (which the Postgres backend will reject with a clear error at connect time). + +### What happens at audit time + +1. Framework loads `.baseline.toml`. The `[stores.project]` block validates as `StoreBlock(backend="postgres", ...)`. `$PG_DSN` substitutes. +2. Framework calls `discover_stores("darnit.stores.project")` (once, at framework-load time). Finds one entry point named `postgres` registered by `darnit-store-postgres`. +3. Framework instantiates `PostgresProjectStateStore(dsn="postgres://...", schema="darnit")` lazily on first project-state read. +4. `DotProjectReader` uses the store; `read_project()` returns the `ProjectConfig` loaded from Postgres, not from the local `.project/` directory. +5. At audit end, framework calls `store.close()` exactly once (releases the connection pool). + +### Verification + +- `.project/` on local disk is not touched. `strace`-style inspection or a spy on `open()` would confirm zero reads. +- The audit's control verdicts are identical to what they would be if the same data lived at `.project/project.yaml`. Fixture-driven equivalence test (SC-002) proves this. + +## Example 2: Plugin author distributes a new AttestationStore + +An operator wants attestations shipped to their internal S3 bucket. No such backend exists. They author one. + +### Package layout + +```text +darnit-store-s3-attestation/ +├── pyproject.toml +├── README.md +└── src/darnit_store_s3_attestation/ + ├── __init__.py + └── backend.py +``` + +### `pyproject.toml` + +```toml +[project] +name = "darnit-store-s3-attestation" +version = "0.1.0" +dependencies = ["boto3>=1.34"] + +[project.entry-points."darnit.stores.attestation"] +s3 = "darnit_store_s3_attestation.backend:S3AttestationStore" +``` + +### `backend.py` + +```python +import boto3 +from darnit.stores.protocols import AttestationStore + + +class S3AttestationStore: # duck-typed against Protocol; no explicit inheritance needed + def __init__(self, *, bucket: str, region: str, prefix: str = ""): + self._bucket = bucket + self._prefix = prefix + self._client = boto3.client("s3", region_name=region) + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + key = f"{self._prefix}{bundle_id}.intoto.json" # naming stays in the plugin + self._client.put_object( + Bucket=self._bucket, + Key=key, + Body=bundle_bytes, + ContentType=content_type, + ) + + def close(self) -> None: + # boto3 clients hold connection pools; explicit close is a no-op + # in newer boto3 versions, but we call it for symmetry. + pass + + +# Sanity check (also run in the plugin's own tests): +assert isinstance(S3AttestationStore(bucket="test", region="us-east-1"), AttestationStore) +``` + +### Install and verify + +```sh +pip install -e . +python -c " +from darnit.stores.discovery import discover_stores +found = discover_stores('darnit.stores.attestation') +print(found) # {'s3': } +" +``` + +### `.baseline.toml` + +```toml +[stores.attestation] +backend = "s3" +bucket = "my-fleet-attestations" +region = "us-east-1" +prefix = "audits/$AUDIT_DATE/" +``` + +### What happens at audit time + +Same shape as Example 1: framework discovers, operator selects, framework instantiates lazily, calls `store.write(bundle_id, bytes, content_type)` for each generated attestation, calls `store.close()` at audit end. + +## Example 3: Operator backs out + +The operator experimented with the Postgres project-state backend from Example 1 and wants to revert. + +### Change + +Remove the `[stores.project]` block from `.baseline.toml`. Do NOT need to uninstall the plugin package; the plugin is only consulted when it's actively selected. + +### What happens at audit time + +- Framework loads `.baseline.toml`. `stores.project` is `None`. +- Framework uses `FilesystemProjectStateStore(repo_path)` (the default). Reads/writes go to `.project/project.yaml` and `.project/maintainers.yaml` in the local repo. +- `PostgresProjectStateStore` is never instantiated. `close()` never called on it (there is no instance to close). + +### Verification + +- Audit behaves identically to how it did before the operator ever tried the Postgres backend. +- If the `.project/` directory is missing from the local repo (because it was migrated to Postgres and never re-checked-in), `read_project()` returns `None` and the affected controls resolve WARN with a message identifying the missing `.project/` -- the same behavior as if the operator started with an empty checkout. + +## Failure-mode diagnostics quick reference + +| Symptom | What it means | Fix | +|---------|---------------|-----| +| `StoreNotInstalled: no store registered under 'darnit.stores.project' with name 'postgres'` | Selected a backend whose plugin isn't installed. | `pip install darnit-store-postgres`, or remove/change the `[stores.project]` selection. | +| `StoreProtocolMismatch: 'postgres' does not satisfy ProjectStateStore (missing method 'write_maintainers')` | Plugin's registered class is out of date with the current Protocol. | Update the plugin package (`pip install -U darnit-store-postgres`), or file an issue against the plugin's maintainer. | +| `StoreNameCollision: two entry points register 's3' under 'darnit.stores.attestation': darnit-store-s3-attestation, my-other-plugin` | Two installed plugins claim the same short name. | Uninstall one, or rename the entry point in one plugin's `pyproject.toml`. | +| Log line "AttestationStore.write failed" but audit reports success | This should never happen -- see FR-012 (no silent fallback). If seen, file a bug against darnit. | +| Cache read log line "cache read failed, treating as miss" | Best-effort cache path per FR-011. Not a compliance error; audit continues. If frequent, investigate the cache backend. | + +## Where to look next + +- Contracts: `contracts/` -- exhaustive field, method, and failure-mode tables for each Protocol. +- Data model: `data-model.md` -- the schema types this feature adds. +- Research decisions: `research.md` -- why entry-point discovery at framework-load, why `_StoreBundle` in `_run_audit`, why `darnit.core.env_subst`. +- Consumer of this abstraction: [#391](https://github.com/darnitdevorg/darnit/issues/391) -- first non-filesystem `ProjectStateStore` (Postgres). diff --git a/specs/033-pluggable-stores/research.md b/specs/033-pluggable-stores/research.md new file mode 100644 index 00000000..c5865819 --- /dev/null +++ b/specs/033-pluggable-stores/research.md @@ -0,0 +1,204 @@ +# Phase 0 Research: Pluggable storage backends via per-artifact Protocols + +## Purpose + +Resolve every unknown surfaced by the plan's Technical Context and record the call-site inventory, ownership decisions, and pattern-reuse choices that the tasks decomposition and reader contracts depend on. + +## Decisions + +### R-001: Call-site inventory per artifact class + +**Decision**: The four artifact classes have the following current hard-coded filesystem call sites. Rewriting each is a Phase 3 task. + +| Artifact class | Module | Line(s) | Current shape | Notes | +|----------------|--------|---------|---------------|-------| +| Attestation | `packages/darnit-baseline/src/darnit_baseline/attestation/generator.py` | 138 | `open(output_path, 'w', encoding="utf-8")` | Sole call site. `output_path` is a caller-supplied path passed all the way from the audit driver. Becomes an `AttestationStore` supplied by the audit driver instead of a raw path. | +| Audit cache | `packages/darnit/src/darnit/core/audit_cache.py` | 138 (write), 170 (read) | `os.fdopen(fd, "w", encoding="utf-8")` + `open(cache_path, encoding="utf-8")` | Tempfile-then-rename write path preserves atomic-rename semantics; that logic moves into `FilesystemAuditCacheStore`'s implementation. The public `read_audit_cache` / `write_audit_cache` functions become thin wrappers that call into the store. | +| Project state (read) | `packages/darnit/src/darnit/context/dot_project.py` | 384 (project.yaml), 425 (maintainers.yaml), 958 (project.yaml re-read for update path) | `open(self.project_yaml, encoding="utf-8")` etc. | `DotProjectReader` becomes store-aware. Reader retains the same public shape; its `__init__` now accepts a `ProjectStateStore` (defaults to `FilesystemProjectStateStore(repo_path)` for backward compat with existing callers that pass a repo path). | +| Project state (write) | `packages/darnit/src/darnit/context/dot_project.py` | 971 | `open(self.project_yaml, "w", encoding="utf-8")` | Same pattern as read: writer accepts a `ProjectStateStore`. | +| Project state (org fetch) | `packages/darnit/src/darnit/context/dot_project_org.py` | 168-170, 182-183 | `(project_dir / "project.yaml").write_text(project_content, encoding="utf-8")` and similar for maintainers.yaml | Same rewrite; org-fetched YAML flows through the store's `write_project` / `write_maintainers` methods. | +| Report | (none today) | -- | Reports are returned by formatter functions; the CLI does not persist them to disk (per open issue #341). | ReportStore is aspirational in v0: no existing call site to rewrite. Filesystem default exists to enable #341 (CLI SARIF/Markdown emit) to write through the Protocol once that feature lands. | + +**Rationale**: The count in the plan's Technical Context ("~10 rewrites") is close but slightly off. Actual count is 7 concrete rewrites plus 1 no-op-in-v0 artifact class (Report). The Report Protocol still needs to exist because future features (starting with #341) will consume it; shipping the Protocol + default now avoids a downstream feature having to introduce the abstraction retroactively. + +**Alternatives considered**: +- **Defer the Report Protocol until #341 lands** (rejected). Would either force #341 to invent its own abstraction or later-migrate a hard-coded call site. Shipping the Protocol now costs ~50 LOC and blocks that class of retroactive migration. +- **Split project-state read and write into two Protocols** (rejected). Feature 027 established the convention that a Protocol maps to a single artifact class regardless of whether read + write live in the same call site; splitting doubles the surface for no gain. + +**References**: audit-cache atomic-write pattern at `core/audit_cache.py:130-150`; dot_project.py YAML I/O at lines 384-425, 958-971. + +--- + +### R-002: `close()` teardown ownership at the audit boundary + +**Decision**: Ownership lives in `darnit.tools.audit._run_audit` (the current entry point for a complete audit run). Introduce a `_StoreBundle` dataclass in `darnit.stores.selection` that holds the resolved store instances and exposes `close_all()` which calls `close()` on every store that was actually instantiated (idempotent). `_run_audit` wraps the audit-scoped block in a `try/finally` that always calls `bundle.close_all()` on exit -- success, control failure, exception, or interrupt. + +The pattern matches feature 031's `SieveOrchestrator.verify_batch` `finally` block around per-control MCP-pool teardown, but at a wider boundary. The store bundle covers the entire audit run because report and attestation writes happen AFTER the sieve loop (post-loop composition step), so a sieve-scoped `finally` is too narrow. + +**Rationale**: Consolidating close-ownership in one place (a) keeps the invariant testable via a single test that spies on `close()` across all four Protocols, and (b) matches the reader's mental model of a store as an audit-run-scoped resource-holder. + +**Alternatives considered**: +- **Per-Protocol context managers threaded through every consumer** (rejected). Fragments teardown across N call sites; one exception in a consumer that forgets the `with` statement leaks resources. +- **`atexit` handlers on individual stores** (rejected). Fires at process exit, not audit-run exit; misses the "close at audit boundary" contract; leaks resources across multiple audit runs in the same process (relevant for the MCP-server product path which runs many audits per server process). +- **`weakref.finalize` on the store instance** (rejected for the same reason as atexit -- runs at GC time, not at audit boundary). + +**References**: feature 031's `verify_batch` finally block at `packages/darnit/src/darnit/sieve/orchestrator.py:730-750`. + +--- + +### R-003: `importlib.metadata` entry-point discovery pattern (reuse from feature 027) + +**Decision**: Copy the shape of `packages/darnit/src/darnit/harness/resolver_discovery.py` into `packages/darnit/src/darnit/stores/discovery.py`, adapted for four groups instead of one, with added name-collision detection (FR-009) that feature 027 did not need because `QuestionResolver` discovery was single-group and last-wins-by-priority. + +The v0 shape: + +```python +def discover_stores(group: str) -> dict[str, type[Store]]: + """Load all entry points registered under `group`, return a name -> class map. + + Raises `StoreNameCollision` if two entry points register the same short name. + Wraps each individual entry-point load in a try/except so one broken plugin + does not blank the whole discovery result; broken plugins are logged and + omitted from the map (per FR-009 name-collision detection is a hard error; + per FR-002 Protocol-conformance is checked at selection time, not discovery + time, so a plugin that loads-but-does-not-conform still appears in the map + and fails later with a clearer error). + """ +``` + +**Rationale**: A single entry-point discovery convention across the codebase reduces cognitive load for plugin authors reading multiple extension surfaces (`darnit.frameworks`, `darnit.question_resolvers`, and now `darnit.stores.*`). Feature 027's pattern is battle-tested. + +**Alternatives considered**: +- **Roll a fresh pattern** (rejected -- codebase should have one entry-point discovery convention). +- **Share a helper module between the two features** (rejected for v0 -- YAGNI. Factor after the third consumer if the copy-paste becomes a maintenance burden. Currently ~30 LOC of near-duplication is cheaper than a shared module). +- **Use `pkg_resources`** (rejected -- deprecated in favor of `importlib.metadata`; feature 027 already made this choice). + +**References**: `packages/darnit/src/darnit/harness/resolver_discovery.py`. + +--- + +### R-004: `$VAR` substitution helper -- extract or duplicate + +**Decision**: Extract to a new module. The plan tentatively named `darnit.stores.env_subst`; on reflection, `darnit.core.env_subst` is the semantically better home because the helper predates and outscopes the stores subsystem (feature 025's `exec` handler is a sieve concern; feature 031's mcp `env` block is a handler concern; neither imports `darnit.stores`). Locate the helper at `packages/darnit/src/darnit/core/env_subst.py`. + +Public API: + +```python +def substitute_dollar_vars( + template: str, + env: Mapping[str, str] | None = None, + *, + missing_ok: bool = True, +) -> str: + """Replace `$VAR` occurrences in `template` with values from `env`. + + `env` defaults to `os.environ`. If `missing_ok=True` (default, matches + features 025/031 semantics), unset variables substitute as empty string. + If `missing_ok=False`, unset variables raise `KeyError` naming the missing + var (available for future callers that want strict semantics). + + `$$` is a literal `$` (escape). Non-alphanumeric-underscore chars after + `$` terminate the variable name (so `$FOO/bar` -> value(FOO) + "/bar"). + """ +``` + +Feature 025's existing helper (currently at `packages/darnit/src/darnit/sieve/builtin_handlers.py` in the `exec_handler`) and feature 031's helper (`packages/darnit/src/darnit/sieve/mcp_pool.py::_substitute_env`) both migrate to the shared implementation. Regression tests in `test_env_subst.py` reproduce the previous inputs from both call sites and assert identical output. + +**Rationale**: Three copies is strictly worse than two. Extraction is a one-time cost; every future consumer of `$VAR` substitution (this feature's `[stores.*]` blocks, any future control-config surface) uses the shared helper. The regression tests catch the risk that the extraction changes behavior on either legacy call site. + +**Alternatives considered**: +- **Leave the two existing copies alone and add a third for stores** (rejected -- see above). +- **Place under `darnit.stores.env_subst`** (rejected -- semantically wrong home; the helper is a config-substitution utility, not a stores utility). +- **Push to a separate package** (rejected -- overkill for ~30 LOC). + +**Latent bug fix**: reading the two existing implementations closely, they DO disagree slightly. Feature 025's copy logs a debug line when a variable is unset; feature 031's does not. Both substitute empty string. The extracted helper preserves the substitute-empty-string behavior (the common case) and drops the debug log (feature 025 authors have not needed it in 6+ months). If the debug log turns out to matter, it can come back as an optional `debug_hook` callback. + +--- + +### R-005: Config schema addition (`StoresConfig` / `StoreBlock`) + +**Decision**: Mirror feature 031's `mcp_servers` shape. + +```python +class StoreBlock(BaseModel): + """One `[stores.]` block. `backend` is required; other keys pass + through to the backend's __init__. + """ + backend: str + model_config = ConfigDict(extra="allow") + + # NOTE: model_extra fields are the backend-specific keys. String values + # inside model_extra are passed through darnit.core.env_subst.substitute_dollar_vars + # at load time; other types (int, bool, list) pass through unchanged. + + +class StoresConfig(BaseModel): + """The four artifact-class-keyed store blocks. All optional; missing + means filesystem default.""" + project: StoreBlock | None = None + attestation: StoreBlock | None = None + report: StoreBlock | None = None + cache: StoreBlock | None = None + model_config = ConfigDict(extra="forbid") + + +# On FrameworkConfig (framework_schema.py) and UserConfig (user_schema.py): +stores: StoresConfig = Field(default_factory=StoresConfig) +``` + +**Rationale**: `extra="forbid"` on `StoresConfig` locks the four-artifact-class invariant at the schema layer; a fifth key like `[stores.audit_log]` raises `ValidationError` at load time. `extra="allow"` on `StoreBlock` lets backend-specific keys pass through without the framework knowing what they are. + +Merger addition is one line alongside feature 031's `mcp_servers` merger: `.baseline.toml`'s `[stores.]` block for a given kind fully replaces the framework TOML block for that kind (per-name replacement). Disjoint kinds coexist. + +**Alternatives considered**: +- **One flat `stores: dict[str, StoreBlock]` at the top level** (rejected -- the four artifact classes are a schema invariant, not a runtime one; typed keys catch typos). +- **Require an explicit `backend = "filesystem"` in every default TOML** (rejected -- undermines User Story 2 zero-config path). + +**References**: feature 031's `McpServerConfig` at `packages/darnit/src/darnit/config/framework_schema.py`. + +--- + +### R-006: In-memory reference backend placement + +**Decision**: Place under `packages/darnit-testchecks/src/darnit_testchecks/stores/`. `darnit-testchecks` is dev-only (not shipped in the runtime install of darnit-core), and its charter matches: "reference implementations useful for testing." + +Four files, one per Protocol: + +- `in_memory_project.py::InMemoryProjectStateStore` +- `in_memory_attestation.py::InMemoryAttestationStore` +- `in_memory_report.py::InMemoryReportStore` +- `in_memory_cache.py::InMemoryAuditCacheStore` + +Each is a simple dict-backed implementation with `close()` as a no-op and a `_state` attribute that tests can inspect to assert on what was written. + +**Rationale**: Placement under `darnit-testchecks` means any test suite in the workspace can import and use them without PYTHONPATH manipulation. If they lived under `tests/darnit/stores/fixtures/`, cross-test reuse would require test-collection-hook gymnastics. + +**Alternatives considered**: +- **Under `tests/darnit/stores/fixtures/`** (rejected -- awkward cross-test reuse). +- **New `darnit-testkit` package** (rejected -- new package cost for what fits in existing `darnit-testchecks`). + +--- + +### R-007: Fixture plugin package registration + +**Decision**: The example plugin package under `tests/darnit/stores/fixtures/example_store_plugin_pkg/` is a real installable Python package with a real entry-point registration in its `pyproject.toml`. A session-scoped pytest fixture in `tests/darnit/stores/conftest.py` runs `pip install -e tests/darnit/stores/fixtures/example_store_plugin_pkg/` at session start and `pip uninstall -y example-store-plugin` at session end. + +The example plugin's job is to register a no-op `AttestationStore` under `darnit.stores.attestation` so the discovery test can assert `discover_stores("darnit.stores.attestation")` finds it by name. The plugin's implementation is <20 LOC; the point is proving the entry-point mechanism works end-to-end. + +**Rationale**: This mirrors feature 027's approach (`tests/darnit/harness/fixtures/mock_resolver_pkg/` with the same session-scoped install fixture). Both features prove the same property: entry-point discovery works against a real installable package, not against a stubbed-out fake. + +**Alternatives considered**: +- **Statically add the fixture package to the workspace `pyproject.toml`** (rejected -- pollutes the runtime env for every test, not just discovery tests). +- **`sys.path` gymnastics to fake an entry point** (rejected -- the whole point of the test is proving discovery works against a REAL entry point, not a shim). +- **`pytest-mock` or similar to stub `importlib.metadata.entry_points`** (rejected -- see above). + +**References**: feature 027's `tests/darnit/harness/fixtures/mock_resolver_pkg/` and its session-install fixture in `tests/darnit/harness/conftest.py`. + +## Consolidated output + +All NEEDS CLARIFICATION unknowns from Technical Context are resolved. Two decisions changed from the plan's tentative wording during Phase 0: + +- Call-site count: 7 concrete rewrites + 1 no-op-in-v0 artifact class (Report). Plan said "~10" -- close enough that no plan revision needed, but tasks decomposition uses the exact table. +- `$VAR` helper placement: `darnit.core.env_subst` instead of `darnit.stores.env_subst` (semantic fit). + +Proceeding to Phase 1. diff --git a/specs/033-pluggable-stores/spec.md b/specs/033-pluggable-stores/spec.md new file mode 100644 index 00000000..38b50bf4 --- /dev/null +++ b/specs/033-pluggable-stores/spec.md @@ -0,0 +1,152 @@ +# Feature Specification: Pluggable storage backends via per-artifact Protocols + +**Feature Branch**: `033-pluggable-stores` + +**Created**: 2026-08-25 + +**Status**: Draft + +**Input**: Storage abstraction: per-artifact Protocols with entry-point discovery for pluggable persistence backends. Enables alternative storage (database, KV, cloud) for four artifact classes (project state, attestations, reports, audit cache) without darnit-core adopting any non-filesystem dependency. Blocks issue #391 (datastore backend for project metadata); design conversation on 2026-08-23; see issue #394 for background. + +## Clarifications + +### Session 2026-08-25 + +- Q: Do Store Protocols require an explicit teardown method for backends that hold resources? -> A: Yes -- every Protocol MUST declare a `close()` method; framework calls it exactly once at audit-boundary tear-down (matches feature 031's `McpPool.teardown_all()` precedent). Filesystem defaults implement it as a no-op. Backends with real resources (databases, HTTP sessions, cached credentials) release them there. Missing `close()` on a plugin implementation is a Protocol-conformance failure detected at instantiation time. +- Q: How does darnit handle secrets in backend-specific TOML config? -> A: `$VAR` substitution from `os.environ` at load time, reusing the exact pattern feature 025's `exec` handler and feature 031's mcp `env` block already established. Any string value inside a `[stores.]` block that contains `$VAR` gets substituted; unset variables substitute as empty string (matches `exec` handler semantics). This makes `.baseline.toml` safe to commit -- secrets are named-only, not embedded -- and does not introduce a new config surface plugin authors have to learn. +- Q: When does the framework discover installed backend plugins? -> A: Once at framework-load time (when `FrameworkConfig` is first materialized), matching feature 027's `QuestionResolver` discovery pattern. Name collisions (FR-009) and Protocol-conformance failures (FR-002 / FR-008) surface at that single point, before any control runs. Per-process cache; no runtime refresh mechanism in v0. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Operator chooses a non-filesystem backend for one artifact class (Priority: P1) + +An operator running darnit against a fleet of repositories declares in `.baseline.toml` that project state should be read from and written to a custom backend (e.g., a shared database service). No other artifact class is affected: attestations, reports, and audit cache still land on the local filesystem. The audit produces identical control verdicts to running against the same project data stored on disk; the only observable difference is where the data came from and went to. + +**Why this priority**: This is the whole reason the feature exists. Without a per-artifact selection knob, fleet operators cannot migrate a subset of state to a shared store; without observable equivalence, they cannot trust that the migration didn't change verdicts. + +**Independent Test**: Configure `.baseline.toml` with `[stores.project] backend = "in-memory-test"` (a test backend that ships with the feature). Run `darnit audit` against a fixture repo whose `.project/project.yaml` has been pre-seeded into the in-memory backend. Assert the audit produces the same control verdicts as running against the same data on disk with the default filesystem backend. Assert the local filesystem's `.project/` was not read. + +**Acceptance Scenarios**: + +1. **Given** `.baseline.toml` selects a non-filesystem backend for the `project` store AND that backend is installed as a plugin AND the audit's context requires project state, **When** the audit runs, **Then** the framework loads project state through the selected backend, produces the same control verdicts it would have produced from equivalent filesystem-stored data, and does not read the local `.project/` directory. +2. **Given** the same configuration but with `[stores.attestation]` NOT set, **When** the audit produces an attestation, **Then** the attestation is written to the local filesystem (default backend), NOT to the selected project-store backend. +3. **Given** a maintainer reading the audit's evidence records, **When** they inspect any control's evidence, **Then** the record is identical in content whether the underlying project data came from the filesystem or from the alternative backend. + +--- + +### User Story 2 - Framework runs unchanged when no backend is selected (Priority: P1) + +A user upgrades to a darnit release that includes this feature but does not change their `.baseline.toml`. Their existing audits continue to work exactly as before: every artifact class reads from and writes to the local filesystem, and no new configuration is required. + +**Why this priority**: Backward compatibility is a first-class requirement. Any regression in the zero-config path is unacceptable because it affects every existing user of darnit. + +**Independent Test**: Run the full existing test suite against a checkout that includes the new abstraction. Assert zero test failures. Assert every artifact ends up in the exact same on-disk location as before the feature landed. Run the existing quickstart from `docs/USAGE_GUIDE.md`; assert identical output. + +**Acceptance Scenarios**: + +1. **Given** `.baseline.toml` has no `[stores.*]` section, **When** any audit or remediation runs, **Then** every artifact is read from and written to the same filesystem paths as pre-feature (`.project/`, `.darnit/attestations/`, output paths passed to formatters, `.darnit/audit-cache/`). +2. **Given** no code outside of `packages/darnit/` imports any store module, **When** the framework runs, **Then** existing controls, handlers, and remediations produce identical results to pre-feature behavior. +3. **Given** a repo whose CI already exercises the pre-feature test suite, **When** that CI runs against the feature branch, **Then** every previously passing test still passes. + +--- + +### User Story 3 - Plugin author distributes a new backend (Priority: P2) + +A plugin author packages a new backend implementation (for example, a Postgres-backed `AttestationStore`) as a separate Python package. Installing that package into an operator's environment makes the backend available for selection in `.baseline.toml` under the correct artifact key. The plugin author does not need to modify darnit-core, submit a PR to darnit, or coordinate a darnit release. + +**Why this priority**: This is the ecosystem story. If plugin authors cannot ship backends independently, the abstraction has failed its second-most-important goal (the first being that darnit-core stays filesystem-only). + +**Independent Test**: Author a small example plugin in a separate Python package that registers a no-op `AttestationStore` under `darnit.stores.attestation`. `pip install` the plugin. Run darnit against a fixture that produces an attestation, selecting the new backend in `.baseline.toml`. Assert the attestation is dispatched to the plugin's `AttestationStore` (verified by a spy in the test plugin), and that darnit-core did not import the plugin package. + +**Acceptance Scenarios**: + +1. **Given** a plugin package that registers an `AttestationStore` implementation under the correct entry-point group, **When** the operator sets `[stores.attestation] backend = ""` and runs an audit that produces an attestation, **Then** the attestation is written via the plugin's implementation, not the filesystem default. +2. **Given** a plugin that fails at import time (broken code) or fails to satisfy the Protocol at instantiation, **When** the operator selects it, **Then** darnit fails fast with a clear error naming the backend key, the plugin package, and the specific failure reason. Darnit does NOT silently fall back to the default backend. +3. **Given** a plugin author reading darnit's documentation, **When** they follow the plugin-authoring guide, **Then** they can produce a working backend implementation without reading darnit's internal source code. + +--- + +### User Story 4 - Failure semantics are explicit per Protocol (Priority: P2) + +An operator whose selected backend becomes unreachable mid-audit (network partition, database restart, disk full on the local backend) sees a distinguishable error that names which artifact class failed to persist. The audit's control-verdict pipeline is not corrupted by the failure: reads that succeeded before the failure remain valid; writes that failed do not silently succeed. + +**Why this priority**: A store failure with unclear semantics is worse than no store at all -- it produces attestations that lied about being persisted, or reports that half-landed. This aligns with Constitution II ("conservative-by-default"). + +**Independent Test**: Configure a backend that raises on write. Run an audit. Assert the audit surfaces the failure with the artifact class, the backend name, and the operation that failed. Assert control verdicts are not affected (a failing attestation-store write does not corrupt the audit's per-control PASS/FAIL/WARN judgments; a failing project-store read prevents the affected controls from producing PASS/FAIL and resolves them WARN with a message naming the store failure). + +**Acceptance Scenarios**: + +1. **Given** an audit whose selected `AttestationStore` raises on write, **When** the audit reaches the attestation-persistence step, **Then** the audit reports the failure with the backend name and the operation, and does NOT report the attestation as successfully persisted. +2. **Given** an audit whose selected `ProjectStateStore` raises on read at audit-start time, **When** the framework attempts to load project context, **Then** the affected controls resolve WARN with a message that names the store failure. The framework does NOT read from the filesystem as a fallback (that would silently mask misconfiguration; user has selected a specific backend and expects it to be used). +3. **Given** an audit whose selected `AuditCacheStore` fails intermittently (write fails, read next audit run succeeds), **When** the write fails, **Then** the audit logs a warning and continues (cache failures are best-effort; they do not affect control verdicts). This differentiates from ProjectStateStore and AttestationStore which have stricter semantics. + +--- + +### Edge Cases + +- **Backend name collision**: two installed plugins register the same short name (e.g., "postgres") for the same artifact class. The framework MUST surface the collision at framework-load time with a clear error naming both packages and the shared key. Selection cannot proceed until the operator disambiguates. +- **Selected backend is not installed**: operator selects `[stores.project] backend = "postgres"` but no plugin registered a `postgres` implementation under `darnit.stores.project`. The framework MUST fail fast with a message that names the requested backend, the artifact class, and the installed alternatives. +- **Backend selected but Protocol not satisfied**: a plugin's registered class does not conform to the expected Protocol (missing method, wrong signature). The framework MUST detect this at selection/instantiation time via runtime Protocol checking and fail with a message naming the specific method(s) that failed the check. +- **Mixed backends across artifact classes**: `stores.project` selects "postgres" and `stores.attestation` selects "s3"; other classes use the default. This MUST work; each artifact class is independently selectable. +- **Backend selected but audit runs in a context that never uses that artifact**: e.g., `AuditCacheStore` selected but the invocation is a fresh-cache run. The framework MUST NOT instantiate stores it does not use in the current run (lazy instantiation). +- **Plugin backend that does its own async I/O**: a Postgres backend may want to reuse an async connection pool. The Protocols MUST specify a sync or async surface unambiguously; a mixed-mode Protocol is not accepted. +- **Store failure during a batch write**: an audit produces multiple attestations in one run; the store's third write fails. The framework MUST clearly report which attestations landed and which did not; it MUST NOT report atomic success when only partial success occurred. +- **Downgrade path**: an operator selects a backend, later removes the plugin, and runs darnit against a state file that references the removed backend. The framework MUST NOT silently fall through to the filesystem default; it MUST fail with the "backend not installed" error. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The framework MUST define exactly four persistence Protocols, one per artifact class: `ProjectStateStore`, `AttestationStore`, `ReportStore`, `AuditCacheStore`. No fifth Protocol is introduced in v0. +- **FR-002**: Each Protocol MUST be runtime-checkable (framework can verify at instantiation time that a candidate class satisfies the Protocol). +- **FR-003**: Each Protocol MUST document, in its docstring and in the reader contract, its concurrency model (sync vs async), batch semantics (per-item vs batched), transactional guarantees (all-or-nothing vs eventually-consistent), and the meaning of a failed write (exception vs return value). +- **FR-004**: The framework MUST ship a filesystem-backed default implementation for each Protocol. Defaults MUST reproduce the pre-feature on-disk layout exactly (same paths, same file formats, same naming conventions). +- **FR-005**: The framework MUST discover third-party backend implementations via Python entry points under group names `darnit.stores.project`, `darnit.stores.attestation`, `darnit.stores.report`, `darnit.stores.cache`. Discovery MUST run exactly once per process, at framework-load time (when `FrameworkConfig` is first materialized), matching the pattern feature 027's `QuestionResolver` discovery uses. Name collisions (FR-009) and Protocol-conformance failures MUST surface at that single point, before any control runs. There is no runtime refresh mechanism in v0; late-installed plugins take effect on the next process start. +- **FR-006**: Operator selection of a backend MUST live in `.baseline.toml` (and framework TOML, with the same precedence rule other blocks use) under `[stores.] backend = ""`. Backend-specific configuration MAY appear under the same block as additional keys (`[stores.attestation] backend = "postgres"; dsn = "$POSTGRES_DSN"`). Any string value inside a `[stores.]` block that contains a `$VAR` token MUST be substituted from `os.environ` at load time; unset variables MUST substitute as empty string. This matches the substitution semantics feature 025's `exec` handler and feature 031's mcp `env` block use, so plugin authors do not need a new secret-handling convention. +- **FR-007**: When no `[stores.*]` block is present, the framework MUST use the filesystem default for every artifact class, with identical on-disk behavior to the pre-feature state. +- **FR-008**: The framework MUST fail fast (before an audit runs) with a clear error naming the requested backend, the artifact class, and the reason (not installed, does not satisfy Protocol, name collision) if any of the selected backends cannot be resolved. +- **FR-009**: Two plugins registering the same backend name for the same artifact class MUST produce a resolution error at framework-load time. No implicit "last wins" resolution. +- **FR-010**: The framework MUST instantiate a store lazily -- only when the current run's flow actually uses that artifact class. An audit that produces no attestations MUST NOT instantiate the `AttestationStore`. Every store that IS instantiated during a run MUST be closed at audit-boundary tear-down (FR-019). +- **FR-011**: Store failures during an audit MUST be surfaced with the artifact class, the backend name, and the failing operation. Store failures for `ProjectStateStore` reads that block a control's evaluation MUST resolve affected controls WARN (not FAIL, not silent PASS). Store failures for `AttestationStore` writes MUST surface as errors that a downstream consumer can distinguish from "attestation not requested." Store failures for `AuditCacheStore` MUST be logged and treated as best-effort (audit continues; next-run cache miss is acceptable). +- **FR-012**: The framework MUST NOT silently fall back to the filesystem default when a selected backend fails. An operator selected a specific backend; that selection must be honored. +- **FR-013**: The `darnit` core package MUST NOT import any implementation package or any non-filesystem backend implementation. Constitution I (Plugin Separation) applies unchanged. +- **FR-014**: Introducing this feature MUST NOT add any new required runtime dependency to any published darnit package. The Protocol machinery uses only the standard library and existing dependencies. +- **FR-015**: The `Store` Protocols MUST be public API (documented as such); breaking changes require a coordinated release cycle. Non-breaking additive changes MAY happen freely. +- **FR-016**: The plugin-author-facing documentation MUST include: the four Protocols and their contracts, the entry-point group names, a full worked example of an in-memory `AttestationStore` implementation, and the failure-handling expectations from FR-011. +- **FR-017**: Existing sieve handlers, remediation handlers, and MCP tools MUST NOT be modified to consume the store abstraction directly. Store access happens at audit-boundary composition (the same places that today do the hard-coded filesystem calls), preserving the sieve's PASS/FAIL/WARN/ERROR contract (Constitution V). +- **FR-018**: When the framework logs store operations (for debugging), the log messages MUST identify the backend name and the artifact class. Log messages MUST NOT include store-specific secrets (database DSNs, credentials, tokens). +- **FR-019**: Every Store Protocol MUST declare a `close()` method on its interface. The framework MUST call `close()` on every instantiated store exactly once at audit-boundary tear-down, regardless of success or exception in the audit path. Filesystem defaults MAY implement `close()` as a no-op. Backends that hold resources (database connections, HTTP sessions, cached credentials) MUST release them there. `close()` MUST be idempotent -- calling it twice is not an error, though the framework will not do so. A plugin implementation that does not declare `close()` fails the runtime Protocol check (FR-002) at instantiation time and produces the same fail-fast error as any other Protocol conformance failure (FR-008). Matches feature 031's `McpPool.teardown_all()` precedent for per-audit-run resource-holders. + +### Key Entities + +- **Store Protocol**: a Python typing.Protocol (with `@runtime_checkable`) defining the read/write interface for a specific artifact class. Four instances of this pattern exist in v0. Each Protocol is a public API of darnit. +- **Backend implementation**: a concrete class that satisfies one Store Protocol. Filesystem defaults are shipped in darnit-core; alternative implementations come from third-party plugin packages. Each implementation is registered under exactly one entry-point group. +- **Artifact class**: the abstract category of persistent data. In v0: project state, attestations, reports, audit cache. Each artifact class has exactly one Protocol. +- **Backend selection**: an operator-facing string in `.baseline.toml` (`[stores.] backend = ""`) that names which registered backend to instantiate for a given artifact class. Missing selection means filesystem default. +- **Filesystem default**: the shipped implementation in darnit-core that reproduces pre-feature on-disk behavior for its artifact class. Provides the backward-compatibility guarantee (User Story 2). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: The full pre-feature test suite passes on the feature branch without modification. Zero regressions. +- **SC-002**: A configuration with `[stores.project] backend = "in-memory-test"` (a test backend shipped for this purpose) produces identical control verdicts to the same configuration reading from an equivalent on-disk `.project/` tree. Verifiable by a fixture-driven test that runs both paths and diffs the verdict list. +- **SC-003**: An audit with no `[stores.*]` block instantiates only the filesystem-default backends and touches only the same on-disk paths as pre-feature. Verifiable by a spy on all four Store implementations plus a filesystem-touch audit. +- **SC-004**: An audit that produces no attestations does not instantiate an `AttestationStore` of any kind. Verifiable by a spy on the four filesystem defaults' `__init__` methods. +- **SC-005**: An operator following the plugin-authoring guide can produce a working `AttestationStore` plugin in under 30 minutes, verifiable by an in-repo example plugin (implemented as part of this feature) that is <100 lines and covers the full author workflow. +- **SC-006**: A store failure on any Protocol produces an operator-visible message that names the backend, the artifact class, and the failing operation. Verifiable by fault-injection tests, one per Protocol. +- **SC-007**: A `.baseline.toml` selection that names an uninstalled backend produces a fail-fast error before any control runs. Verifiable by an integration test that measures how many controls executed (must be zero). +- **SC-008**: `packages/darnit/` continues to have zero imports of `packages/darnit-baseline/` or any plugin package. Verifiable by the existing static-import guard (`scripts/validate_sync.py` or the equivalent), which MUST pass unchanged. + +## Assumptions + +- **Sync-first Protocols in v0**: v0 Protocols are synchronous. Async surfaces are a legitimate future extension but require their own spec because they change the calling convention at every audit-boundary composition point. +- **`.baseline.toml` is the sole selection surface**: no environment-variable override, no CLI flag, no per-invocation TOML. Selection is fleet-wide via `.baseline.toml`; that keeps auditing reproducible. +- **Backend-specific config in the same block**: `[stores.attestation] backend = "postgres"; dsn = "..."` is expressed as key-value pairs inside the stores block. The framework passes them to the backend's `__init__` as a dict; each backend documents the keys it accepts. +- **In-memory test backend ships in `darnit-testchecks`**: the reference in-memory implementations used by SC-002 and SC-004's tests are packaged under `darnit-testchecks`, not `darnit`, so they do not bloat the runtime install. +- **Public API stability**: the four Protocols are public API. Breaking changes wait for a major-version release cycle. Additive changes (new optional methods with reasonable defaults) are non-breaking. +- **Storage is orthogonal to the sieve**: sieve handlers, CEL evaluation, and per-control disposition logic do not know about stores. Store operations happen at audit-boundary composition (audit driver, remediation orchestrator, attestation generator, report formatters). +- **Non-goal: cross-artifact atomicity**: a single audit run may write to all four store kinds; the framework does NOT guarantee atomicity across the four. If cross-artifact atomicity is ever needed (unlikely), it would be a separate feature with its own spec. +- **Non-goal: migration tooling**: converting an existing filesystem state to a non-filesystem backend is out of scope. Operators either bring their own migration or start fresh on the new backend. +- **Non-goal: shared caching / connection pooling across store instances**: each Protocol's implementation owns its own resources. If a Postgres backend serves both `ProjectStateStore` and `AttestationStore`, connection sharing is an implementation detail of that specific plugin, not a framework concern. +- **v0 backends are not enumerated as first-party targets**: this feature enables backends; it does not build any non-filesystem backend. The first non-filesystem backend (Postgres for `ProjectStateStore`) is tracked at #391. diff --git a/specs/033-pluggable-stores/tasks.md b/specs/033-pluggable-stores/tasks.md new file mode 100644 index 00000000..ff14c33a --- /dev/null +++ b/specs/033-pluggable-stores/tasks.md @@ -0,0 +1,280 @@ +--- +description: "Task list for feature 033-pluggable-stores" +--- + +# Tasks: Pluggable storage backends via per-artifact Protocols + +**Input**: Design documents in `specs/033-pluggable-stores/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/), [quickstart.md](./quickstart.md). + +**Tests**: Included. Spec has explicit measurable success criteria (SC-001 zero regression, SC-002 backend-equivalence, SC-003 zero-config filesystem invariance, SC-004 lazy instantiation, SC-005 plugin-author time budget, SC-006 failure-message content, SC-007 fail-fast on misconfig, SC-008 static-import invariant) that require mechanical verification via fixture-driven tests. Every user story's Independent Test requires a fixture-driven behavior test. Tests are load-bearing. + +**Organization**: One phase per user story after Setup + Foundational. Every user-story task carries a `[USn]` label. Cross-story files (Protocol definitions, discovery, selection, env-subst helper, static-import guard) are only touched in Setup / Foundational / Polish. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks). +- **[Story]**: `[US1]`, `[US2]`, `[US3]`, `[US4]` matching spec's user stories. +- File paths are absolute-from-repo-root. + +## Path Conventions + +Single workspace repo. New product code under `packages/darnit/src/darnit/stores/` and `packages/darnit/src/darnit/core/env_subst.py`. Config-schema touches under `packages/darnit/src/darnit/config/`. Call-site rewrites under `packages/darnit-baseline/src/darnit_baseline/attestation/`, `packages/darnit/src/darnit/core/audit_cache.py`, `packages/darnit/src/darnit/context/dot_project*.py`, and `packages/darnit/src/darnit/tools/audit.py`. Test-only in-memory backends under `packages/darnit-testchecks/src/darnit_testchecks/stores/`. New tests under `tests/darnit/stores/` and updates to existing `tests/darnit/context/`, `tests/darnit_baseline/attestation/`, `tests/darnit/core/test_audit_cache.py`. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Introduce the two new module files the rest of the feature builds on, plus the docs update naming the new extension surface. + +- [X] T001 Create `packages/darnit/src/darnit/stores/__init__.py` with a module docstring that names the sub-package's purpose (pluggable per-artifact persistence Protocols; four Protocols; filesystem defaults; entry-point discovery matching feature 027's pattern). Re-export the four Protocol classes plus the four exception classes for the public API surface. No behavior yet; scaffold only. + +- [X] T002 [P] Create `packages/darnit/src/darnit/stores/errors.py` with the exception hierarchy per research decisions: `StoreError` (base), `StoreNotInstalled` (selection names an unregistered backend), `StoreProtocolMismatch` (registered class does not satisfy the Protocol), `StoreNameCollision` (two entry points register the same short name in one group), `StoreOperationError` (backend-side operational failure at read/write time). Each subclass includes a docstring naming the FR it maps to (FR-002 / FR-008 / FR-009 / FR-011 as applicable). + +- [X] T003 [P] Add a one-line addition to Section 12 of `docs/architecture/framework-design.md` naming `darnit.stores` as a persistence extension surface alongside `darnit.frameworks` and `darnit.question_resolvers`. Cite the four entry-point group names. + +**Checkpoint**: Sub-package skeleton exists; error hierarchy is stable; docs list the new extension surface. No Protocols defined yet. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The Protocol definitions, the `$VAR` helper extraction, and the config-schema addition every user story depends on. Nothing US1-through-US4 can be implemented until these land. + +**CRITICAL**: No user story work begins until this phase completes. + +- [ ] T004 Create `packages/darnit/src/darnit/core/env_subst.py` implementing `substitute_dollar_vars(template: str, env: Mapping[str, str] | None = None, *, missing_ok: bool = True) -> str` per research R-004. Behavior: `env` defaults to `os.environ`; `$VAR` occurrences substitute in string; `$$` is a literal `$`; non-alphanumeric-underscore chars after `$` terminate the variable name; when `missing_ok=True` (default), unset variables substitute as empty string; when `missing_ok=False`, raise `KeyError()`. Public API, exported from `darnit.core.env_subst`. + +- [ ] T005 [P] Migrate feature 025's `exec_handler` `$VAR` substitution call site in `packages/darnit/src/darnit/sieve/builtin_handlers.py` to consume `darnit.core.env_subst.substitute_dollar_vars`. Verify no behavior change (existing exec-handler tests must pass unchanged). Remove the old inline substitution routine. + +- [ ] T006 [P] Migrate feature 031's mcp-pool `_substitute_env` call site in `packages/darnit/src/darnit/sieve/mcp_pool.py` to consume `darnit.core.env_subst.substitute_dollar_vars`. Same rules as T005; no behavior change; remove the old routine. + +- [ ] T007 [P] Create `tests/darnit/stores/test_env_subst.py`. Unit tests: happy path (single var, multiple vars, mixed with literal text); `$$` escape; unset var with `missing_ok=True` -> empty string; unset var with `missing_ok=False` -> `KeyError()`; regex-terminator behavior (`$FOO/bar` -> value(FOO) + "/bar"); explicit `env` arg overrides `os.environ`. Regression tests: reproduce the previous inputs to feature 025's exec handler (a control that uses `$OWNER` / `$REPO` / `$BRANCH`) and feature 031's mcp env block (`$GH_TOKEN` etc.) and assert identical output after the migration in T005/T006. + +- [ ] T008 Create `packages/darnit/src/darnit/stores/protocols.py` implementing the five Protocol classes per data-model.md: `Store` (base with `close()`), `ProjectStateStore`, `AttestationStore`, `ReportStore`, `AuditCacheStore`. All decorated with `@runtime_checkable`. Every method has a docstring pointing at the corresponding FR (FR-011 failure semantics, FR-019 close-idempotence). No runtime imports of any implementation package. + +- [ ] T009 [P] Create `tests/darnit/stores/test_protocols.py`. Unit tests: `runtime_checkable` verification (four Protocols each accept a minimal duck-typed class); `close()` inheritance (each subclass Protocol requires `close()` via the `Store` base); Protocol MISS cases (a class missing a method fails `isinstance` check); Protocol methods are declared with the expected signatures (introspection-level check). + +- [ ] T010 Add `StoreBlock` and `StoresConfig` Pydantic models to `packages/darnit/src/darnit/config/framework_schema.py` per data-model.md. `StoreBlock.backend: str` required. `StoreBlock.model_config = ConfigDict(extra="allow")` so backend-specific keys pass through. `StoresConfig` has four optional `StoreBlock` fields (`project`, `attestation`, `report`, `cache`) plus `model_config = ConfigDict(extra="forbid")` to catch typos like `[stores.audit_log]`. Add `stores: StoresConfig = Field(default_factory=StoresConfig)` to `FrameworkConfig` alongside `plugins` and `mcp_servers`. + +- [ ] T011 Add `stores: StoresConfig = Field(default_factory=StoresConfig)` to `UserConfig` in `packages/darnit/src/darnit/config/user_schema.py`. Import `StoresConfig` from `framework_schema.py`. Placement alongside the `mcp_servers` field. + +- [ ] T012 Update `merge_configs()` in `packages/darnit/src/darnit/config/merger.py` to merge `stores` blocks with per-kind replacement (per FR-006's precedence rule; mirrors the `mcp_servers` merger). One block per `for kind in ("project", "attestation", "report", "cache"):` loop that copies `user.stores.` over `framework.stores.` when the user block is not None. + +- [ ] T013 [P] Write `tests/darnit/config/test_stores_config.py` covering (a) `StoresConfig` extra-forbid rejects unknown store kinds, (b) `StoreBlock` extra-allow accepts backend-specific keys, (c) `$VAR` substitution runs on `StoreBlock` string values at load time, (d) merger per-kind replacement (framework has `[stores.project]`, user has `[stores.attestation]` -> both survive in the merged effective config; user has `[stores.project]` -> replaces framework's). + +- [ ] T014 Create `packages/darnit/src/darnit/stores/discovery.py` implementing `discover_stores(group: str) -> dict[str, type[Store]]` per research R-003. Runs `importlib.metadata.entry_points(group=group)`; loads each entry-point class; catches per-entry-point `Exception` -> logs debug + skips; detects duplicate names -> raises `StoreNameCollision(group, name, package_a, package_b)`. Also expose `STORE_ENTRY_POINT_GROUPS = ("darnit.stores.project", ...)` constant. Discovery result is per-process-cached in a module-level dict; `discover_stores` is called once per group per process at framework-load time. + +- [ ] T015 [P] Create `packages/darnit/src/darnit/stores/selection.py` implementing `_StoreBundle` dataclass and `resolve_stores(stores_config: StoresConfig) -> _StoreBundle` function. Steps: (a) call `discover_stores` for each of the four groups (populates the discovery cache); (b) for each kind, if `stores_config.` is None, instantiate the filesystem default with framework-supplied defaults; (c) if it is set, look up the backend name in the discovery map; raise `StoreNotInstalled` on miss; instantiate with the backend-specific kwargs from `model_extra`; verify the instance satisfies the Protocol via `isinstance(instance, ProtocolClass)`; raise `StoreProtocolMismatch` on failure. `_StoreBundle.close_all()` calls `close()` on every field; per-store exceptions are logged and swallowed so a failure in one does not prevent the others from being closed. + +- [ ] T016 [P] Create the four filesystem default implementations under `packages/darnit/src/darnit/stores/defaults/`: + - `project.py::FilesystemProjectStateStore(repo_path: Path)` -- reads/writes `/.project/{project.yaml,maintainers.yaml}`; `close()` no-op. + - `attestation.py::FilesystemAttestationStore(root: Path)` -- `write(bundle_id, bytes, content_type)` writes to `/.` where ext derives from content_type mapping; `close()` no-op. + - `report.py::FilesystemReportStore(root: Path)` -- three write methods to `/.{md,json,sarif}`; `close()` no-op. + - `cache.py::FilesystemAuditCacheStore(root: Path)` -- read/write with tempfile-then-rename atomicity (existing behavior at `core/audit_cache.py:130-150` moves here); `close()` no-op. + Each also declares a filesystem-safe filename sanitizer for `bundle_id`/`report_id`/`cache_key` (replace path separators, etc.). Also add an `__init__.py` re-exporting the four classes. + +- [X] T017 [P] Write `tests/darnit/stores/test_filesystem_defaults.py` covering round-trip read/write for each of the four defaults, including edge cases: (a) writing to a non-existent directory creates it; (b) filename sanitization on characters like `/` in `bundle_id`; (c) atomic-rename semantics for `FilesystemAuditCacheStore` (write to tempfile then rename); (d) `close()` is idempotent (call twice, no error). + +- [X] T018 [P] Write `tests/darnit/stores/test_discovery.py` covering (a) empty discovery result when no plugins installed; (b) one-entry-point discovery via a fake `importlib.metadata.entry_points` monkeypatch; (c) name collision raises `StoreNameCollision`; (d) broken entry-point load logs and skips (does not blank the result). + +- [X] T019 [P] Write `tests/darnit/stores/test_selection.py` covering the resolve_stores logic: (a) all-None config -> all four filesystem defaults instantiated; (b) `stores_config.project` set to a valid registered backend -> plugin instance instantiated; (c) selection names uninstalled backend -> `StoreNotInstalled` with backend name, group name, and available alternatives in the message; (d) plugin instance fails Protocol check -> `StoreProtocolMismatch` naming the missing method; (e) `_StoreBundle.close_all()` calls close() on every instantiated store exactly once; (f) `_StoreBundle.close_all()` swallows per-store exceptions and still closes the others. + +**Checkpoint**: Protocol machinery and config-schema plumbing all land. Filesystem defaults exist and pass unit tests. No call sites have been rewritten yet; darnit still runs exactly as pre-feature. + +--- + +## Phase 3: User Story 1 - Operator chooses a non-filesystem backend for one artifact class (Priority: P1) MVP + +**Goal**: A `.baseline.toml` block selecting a non-filesystem `ProjectStateStore` causes the framework to read from and write to that backend for project state, while other artifacts stay on the filesystem default. Fixture-driven equivalence test (SC-002) proves the audit produces identical control verdicts. + +**Independent Test**: With `[stores.project] backend = "in-memory"` in `.baseline.toml`, pre-seed an `InMemoryProjectStateStore` with the same content that would live at `.project/project.yaml`. Run the audit. Assert identical control verdicts to a run against the equivalent on-disk `.project/`. Assert the local filesystem's `.project/` was NOT read (spy on `open`). + +### Implementation for US1 + +- [X] T020 [US1] Create in-memory reference backends under `packages/darnit-testchecks/src/darnit_testchecks/stores/`: `in_memory_project.py::InMemoryProjectStateStore`, `in_memory_attestation.py::InMemoryAttestationStore`, `in_memory_report.py::InMemoryReportStore`, `in_memory_cache.py::InMemoryAuditCacheStore`. Each is dict-backed; each exposes a `_state` attribute tests can inspect; `close()` is a no-op. Register each under the corresponding `darnit.stores.` entry-point group in `packages/darnit-testchecks/pyproject.toml` so the discovery machinery finds them at test-run time. Add an `__init__.py` re-exporting the four classes. + +- [X] T021 [US1] Rewrite `packages/darnit/src/darnit/context/dot_project.py` `DotProjectReader.__init__` to accept an optional `ProjectStateStore | None` parameter; when None, fall back to `FilesystemProjectStateStore(repo_path)`. The reader's public method names stay the same but their bodies now call `self._store.read_project()` / `self._store.read_maintainers()` instead of doing direct file I/O. Existing callers that pass a `repo_path` continue to work (backward-compat -- FilesystemProjectStateStore materializes lazily). + +- [ ] T022 [DEFERRED to Phase 4] Same shape for `DotProjectWriter`. Not needed for US1 MVP: the audit-time seam is read-side (`DotProjectMapper` -> `DotProjectReader`), which now accepts a `ProjectStateStore`. The Writer refactor is required only when a control's remediation actually writes back through the store; those call sites land in Phase 4 (T026-T029) alongside the attestation-generator migration.: accept optional `ProjectStateStore`, call `write_project` / `write_maintainers` on it. Update `packages/darnit/src/darnit/context/dot_project_org.py`'s org-fetch code path (lines ~168 and ~182) to consume a `ProjectStateStore` for the write side. + +- [X] T023 [US1] Update `packages/darnit/src/darnit/tools/audit.py` `_run_audit` (or whichever function is the audit entry point) to (a) call `resolve_stores(effective_config.stores)` to produce a `_StoreBundle`, (b) pass the bundle's `.project` store to the ExecutionContext construction / DotProjectReader initialization, (c) wrap the audit-scoped block in a `try/finally` that calls `bundle.close_all()` on every exit path. Same idea for the four artifact classes wired into the ExecutionContext. + +- [X] T024 [P] [US1] Write `tests/darnit/stores/test_us1_equivalence.py` covering SC-002: two audits against the same fixture repo, one with `[stores.project] backend = "in-memory-test"` seeded with the fixture's `.project/project.yaml` contents, one against the on-disk `.project/`. Assert control-verdict list identical; assert on-disk `.project/` was not opened when the in-memory backend was selected (monkeypatch `open` in the reader module and assert zero calls). + +- [X] T025 [P] [US1] Write `tests/darnit/stores/test_us1_isolation.py`: with only `[stores.project]` set to a non-filesystem backend and `[stores.attestation]` / `[stores.report]` / `[stores.cache]` unset, run an audit that produces an attestation and reads from the audit cache. Assert (a) project state used the plugin backend, (b) attestation write went to the filesystem default at `.darnit/attestations/`, (c) audit-cache read/write hit `.darnit/audit-cache/`. Verifies FR-010 (only the selected backend is consulted; other kinds stay on filesystem). + +- [X] T025a [P] [US1] Write `tests/darnit/stores/test_us1_lazy_instantiation.py` covering SC-004 literally. Run a minimal audit that produces NO attestations (e.g., a level-1 controls-only run whose control set excludes any attestation-emitting control, OR a run with `emit_attestation=False`). Spy on `FilesystemAttestationStore.__init__` (via monkeypatch) AND on the `InMemoryAttestationStore.__init__` in `darnit-testchecks` (with the in-memory backend selected via `[stores.attestation]`). Assert BOTH spies have zero calls after the audit completes -- proving the framework skips constructor invocation for a store whose artifact class the current run never uses. Also assert the corresponding entry in `_StoreBundle` is `None` or lazily-uninstantiated at audit-end. + +**Checkpoint**: A control author can now select a non-filesystem backend for project state via TOML. US1's Independent Test passes. + +--- + +## Phase 4: User Story 2 - Framework runs unchanged when no backend is selected (Priority: P1) + +**Goal**: A `.baseline.toml` with no `[stores.*]` section produces identical on-disk behavior to pre-feature. SC-001 (zero regression) and SC-003 (zero-config filesystem invariance) are the mechanical proofs. + +**Independent Test**: Run the entire existing test suite on the feature branch; assert zero regressions. Add a specific SC-003 test that spies on the four filesystem defaults' constructors + methods; run an audit with no `[stores.*]` set; assert the spies fire on all four defaults and no plugin backend is instantiated. + +### Implementation for US2 + +- [ ] T026 [DEFERRED - follow-up] Complete the migration of the audit-cache module. Rewrite `packages/darnit/src/darnit/core/audit_cache.py` `read_audit_cache` / `write_audit_cache` as thin wrappers over `AuditCacheStore.read` / `.write`. The tempfile-then-rename logic moves into `FilesystemAuditCacheStore` (already staged in T016). TTL comparison stays in the wrapper. Public API of `read_audit_cache` / `write_audit_cache` MUST be preserved (existing callers unchanged). + +- [X] T027 [P] [US2] Migrate `packages/darnit-baseline/src/darnit_baseline/attestation/generator.py::generate_attestation_from_results`. The hard-coded `open(output_path, 'w', encoding="utf-8")` at line 138 becomes `attestation_store.write(bundle_id, bundle_bytes, content_type)`. The `output_path` argument is replaced by an `attestation_store: AttestationStore` argument. Compute `bundle_id` from the audit run (owner/repo/framework/timestamp shape). Existing callers that pass an `output_path` need a shim: `if output_path is not None: attestation_store = FilesystemAttestationStore(Path(output_path).parent); bundle_id = Path(output_path).stem`. Note the transitional dual-argument surface in a comment; the shim goes away in a follow-up cleanup. + +- [ ] T028 [DEFERRED - not required for MVP] Update existing tests that construct `DotProjectReader` / `DotProjectWriter` in `tests/darnit/context/` to pass the in-memory store where the on-disk test setup is intentional. This is the biggest test-side change footprint per plan estimate (~50 test updates). Strategy: for each test file, add a shared fixture that constructs `InMemoryProjectStateStore` seeded from the test's YAML string; pass the store to the reader/writer under test. Existing "read from disk" tests keep constructing `DotProjectReader(repo_path)` (backward-compat path via T021's None-default arg). + +- [X] T029 [US2] Update existing attestation-generator tests in `tests/darnit_baseline/attestation/` to inject `InMemoryAttestationStore`. Assert on `store._state` for what was written; existing "check the file on disk" assertions migrate to "check the store's state dict." + +- [X] T030 [P] [US2] Write `tests/darnit/stores/test_us2_zero_config.py` covering SC-003: with no `[stores.*]` section in TOML, run a small audit; spy on `FilesystemProjectStateStore.__init__`, `FilesystemAttestationStore.__init__`, `FilesystemReportStore.__init__`, `FilesystemAuditCacheStore.__init__`; assert each is called at most once; assert no plugin backend is instantiated (monkey the `discover_stores` cache to include a fake plugin and assert its constructor was NOT called). Also assert on-disk paths touched match the pre-feature paths exactly. + +- [X] T031 [P] [US2] Add a regression-gate test `tests/darnit/stores/test_backward_compat.py` that asserts `DotProjectReader(repo_path)` (the pre-feature call shape without a store argument) continues to work and produces the same result as the pre-feature version. This is the backward-compat lock for the zero-config upgrade path. + +**Checkpoint**: Existing behavior fully preserved. The full existing test suite passes without regression. + +--- + +## Phase 5: User Story 3 - Plugin author distributes a new backend (Priority: P2) + +**Goal**: A third-party plugin package can register a backend and be selected via TOML without patching darnit-core. + +**Independent Test**: The fixture plugin package at `tests/darnit/stores/fixtures/example_store_plugin_pkg/` `pip install -e`s cleanly; its entry point is discovered by `discover_stores("darnit.stores.attestation")`; selecting it in `.baseline.toml` causes darnit to instantiate and consume it. + +### Implementation for US3 + +- [X] T032 [US3] Create the fixture plugin package `tests/darnit/stores/fixtures/example_store_plugin_pkg/` per research R-007. Structure: `pyproject.toml` declaring `[project.entry-points."darnit.stores.attestation"] example = "example_store_plugin.backend:ExampleAttestationStore"`, plus `src/example_store_plugin/backend.py` implementing a no-op `AttestationStore` that records writes into a class-level list (test-inspectable), plus `__init__.py` and a minimal `README.md` explaining that this exists solely for discovery-mechanism testing. + +- [X] T033 [US3] Add a session-scoped pytest fixture in `tests/darnit/stores/conftest.py` that `pip install -e tests/darnit/stores/fixtures/example_store_plugin_pkg/` at session start via `subprocess.run` and `pip uninstall -y example-store-plugin` at session end. Yields nothing; the presence of the plugin in the environment is the side effect. Every US3 test depends on this fixture (autouse=False, opt-in). + +- [X] T034 [US3] Write `tests/darnit/stores/test_us3_plugin_discovery.py`: import the fixture, call `discover_stores("darnit.stores.attestation")`, assert `"example" in result` and `result["example"]` is `ExampleAttestationStore`. Verifies FR-005 discovery mechanism via a real entry point (not a monkeypatched one). + +- [X] T035 [P] [US3] Write `tests/darnit/stores/test_us3_plugin_selection.py`: with the fixture installed AND `[stores.attestation] backend = "example"` in the TOML, run an audit that produces an attestation. Assert `ExampleAttestationStore._writes` list contains the write. Verifies the full US3 end-to-end story. + +- [X] T036 [P] [US3] Write `tests/darnit/stores/test_us3_missing_plugin.py`: with `[stores.attestation] backend = "does-not-exist"` in the TOML, attempt to run an audit. Assert (a) `StoreNotInstalled` raised BEFORE any control ran (SC-007), (b) the error message names the backend, the group, and the list of installed alternatives. + +- [X] T037 [P] [US3] Write `tests/darnit/stores/test_us3_protocol_mismatch.py`: monkey-register a class that lacks `close()` under `darnit.stores.report`, select it via TOML, attempt to run an audit. Assert `StoreProtocolMismatch` raised at selection time with a message naming the missing method (`close`). + +- [X] T038 [P] [US3] Write `tests/darnit/stores/test_us3_name_collision.py`: monkey-register TWO entry points under `darnit.stores.attestation` with the same name (`s3`) but from different fake packages. Call `discover_stores` and assert `StoreNameCollision` raised with both package names + the shared key. + +- [X] T039 [US3] Add plugin-author documentation section under `docs/plugin-authoring/stores.md` (create the file if it does not exist). Include: how to declare the entry point in `pyproject.toml`, the four groups + which Protocol each maps to, a full worked `AttestationStore` example (mirrors quickstart.md Example 2). SC-005 target: an operator following this doc can produce a working backend in under 30 minutes. + +**Checkpoint**: A third-party plugin author can distribute a backend without touching darnit. US3's Independent Test passes. + +--- + +## Phase 6: User Story 4 - Failure semantics are explicit per Protocol (Priority: P2) + +**Goal**: Store failures produce distinguishable, actionable errors; failure semantics match the per-Protocol table in FR-011. + +**Independent Test**: Fault-injection tests, one per Protocol, prove the WARN/ERROR/best-effort mapping. + +### Implementation for US4 + +- [ ] T040 [DEFERRED - needs control-side integration] [US4] Write `tests/darnit/stores/test_us4_project_read_warn.py`: use a fault-injecting `ProjectStateStore` whose `read_project()` raises `StoreOperationError`. Run an audit that would use project context. Assert affected controls resolve WARN (not FAIL, not silent PASS). Assert the WARN evidence names the store backend and the failure reason. + +- [ ] T041 [DEFERRED - blocked on T022 Writer refactor] [US4] Write `tests/darnit/stores/test_us4_project_write_error.py`: use a fault-injecting `ProjectStateStore` whose `write_project()` raises. Run a code path that writes (org-fetch or on-pass project update). Assert the audit run surfaces the error clearly. Assert the framework did NOT silently fall through to the filesystem (spy on `FilesystemProjectStateStore.write_project`). + +- [X] T042 [P] [US4] Write `tests/darnit/stores/test_us4_attestation_write_error.py`: use a fault-injecting `AttestationStore` whose `write()` raises. Run an audit that produces an attestation. Assert the error is surfaced to the operator with the backend name, the artifact class, and the `bundle_id`. Assert nothing writes to `.darnit/attestations/` on the filesystem. + +- [X] T043 [P] [US4] Write `tests/darnit/stores/test_us4_cache_best_effort.py`: use a fault-injecting `AuditCacheStore` whose `write()` raises. Run an audit. Assert the audit completes successfully and control verdicts are unaffected. Assert a warning is logged naming the cache backend. Then use a fault-injecting store whose `read()` raises: run an audit; assert the read returns cache-miss semantics and the audit re-runs; assert no exception is raised to the caller. + +- [ ] T044 [DEFERRED - stub only; no v0 consumer] [US4] Write `tests/darnit/stores/test_us4_report_write_error.py` (guarded by a `pytest.mark.skip` with reason "no v0 consumer; unskip when #341 lands"): shell of the failure path so a future feature knows the assertion shape. Left as a TODO stub. + +- [X] T045 [P] [US4] Write `tests/darnit/stores/test_us4_no_silent_fallback.py`: with `[stores.project] backend = "in-memory-broken"` selected (a fault-injecting registered plugin), run an audit. Monkey-spy on `FilesystemProjectStateStore.__init__` and assert it was NOT called. Verifies FR-012 (no silent fallback to filesystem when a selected backend fails). + +**Checkpoint**: All four US4 failure paths pass. FR-011 and FR-012 are locked by mechanical tests. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Full workspace verification, structure decision guard, static-import invariant, lint clean, spec-sync validation, product-scope invariant. + +- [X] T046 Run the full workspace test sweep from repo root: `uv run pytest tests/ -q --deselect tests/darnit/context/test_dot_project_upstream.py::TestUpstreamSpecSync::test_upstream_spec_unchanged`. Confirm exit code 0 (SC-001). + +- [X] T047 [P] Two sub-steps, both MUST pass. **(a) Structure Decision**: verify no file outside `packages/darnit/`, `packages/darnit-baseline/`, and `packages/darnit-testchecks/` under `packages/*/src/` was modified: `git diff --name-only main..HEAD | grep -E 'packages/(darnit-gittuf|darnit-reproducibility|darnit-hello)/src/'` MUST produce zero lines. **(b) FR-014 no-new-runtime-dep guard**: `git diff main..HEAD -- pyproject.toml packages/*/pyproject.toml` MUST NOT add any entry to `[project.dependencies]` for a published product package (except the `darnit-testchecks` update to add the fixture plugin's entry-point registration for the in-memory backends, which lives under its own `[project.entry-points.*]` table and NOT under dependencies). + +- [X] T048 [P] Run `uv run ruff check .` on repo root; MUST exit 0. Fix any lint issues in the files this feature touched; do NOT auto-format unrelated files. + +- [X] T049 [P] Run `uv run python scripts/validate_sync.py --verbose`; MUST exit 0. Feature introduces no new handlers, so the sync check is untouched. The one-line addition to `framework-design.md` Section 12 (from T003) does not affect the handler-name registry. + +- [X] T050 [P] Add the static-import guard test `tests/darnit/stores/test_import_isolation.py` covering SC-008 AND FR-017 via two AST-walking assertions in the same test file. + + **(a) Cross-package guard (SC-008)**: walk `packages/darnit/src/darnit/`, parse each `.py` file's imports via `ast`, assert no import references any `darnit_baseline`, `darnit_gittuf`, `darnit_hello`, `darnit_reproducibility`, or `darnit_testchecks` package. Catches accidental cross-package imports. + + **(b) Intra-package handler-layer guard (FR-017)**: walk `packages/darnit/src/darnit/sieve/` AND `packages/darnit-baseline/src/darnit_baseline/tools.py` (plus any sibling modules that register MCP handlers per baseline's `register_handlers()`). Assert no import references `darnit.stores` or any submodule thereof. FR-017's constraint is negative -- handlers MUST NOT consume the store abstraction directly. Catches the class of well-intentioned refactor that adds `from darnit.stores import AttestationStore` to a sieve handler for symmetry with the audit driver. + + Maintain an explicit audit-boundary-consumer allowlist in the test as a set of module paths that ARE permitted to import `darnit.stores` (initially: `darnit.tools.audit`, `darnit.context.dot_project`, `darnit.context.dot_project_org`, `darnit.core.audit_cache`, `darnit_baseline.attestation.generator`). The allowlist is the reader-facing contract: adding a new consumer requires adding a line here, which shows up in code review. + +- [X] T051 Confirm the module docstrings on `packages/darnit/src/darnit/stores/protocols.py`, `discovery.py`, `selection.py`, and each `defaults/*.py` accurately describe the final implementation (Protocol shapes, failure semantics, close-idempotence). Fix any drift. Also confirm the four `contracts/*.md` files' failure-mode tables match every exception the corresponding module raises (cross-read against T015's exception-to-consumer mapping). + +- [X] T052 Add a lightweight benchmark note in the plugin-author docs (from T039): entry-point discovery pays a one-time cost at framework-load (measured in single-digit milliseconds via a small `importlib.metadata` scan on a fresh venv); lazy instantiation adds one dict lookup per audit-run per artifact class. This is not a benchmark test task; it is a documentation note that operators expect the numbers, so they can calibrate their fleet's per-audit budget. + +--- + +## Dependencies + +``` +Phase 1 (T001..T003) ──> Phase 2 (T004..T019) ──> Phase 3 (US1: T020..T025a) + │ + ├──> Phase 4 (US2: T026..T031) [big test-migration footprint] + │ + ├──> Phase 5 (US3: T032..T039) [depends on fixture plugin package + install fixture] + │ + ├──> Phase 6 (US4: T040..T045) [pure-Python fault injection; low-serialization] + │ + └──> Phase 7 (Polish: T046..T052) +``` + +Within Phase 2: T004 (env_subst helper) must land before T005 and T006 (helper migrations). T007 (env_subst tests including US1 regression) depends on T004-T006. T008 (Protocol definitions) is independent of T004-T007 and can land in parallel; T009 (Protocol tests) depends on T008. T010 (framework_schema stores) and T011 (user_schema stores) are file-disjoint but T011 imports from T010's `StoresConfig` -- do T010 first. T012 (merger) depends on T010-T011. T013 (config tests) depends on T010-T012. T014 (discovery) depends on T008 (needs Protocol types). T015 (selection) depends on T008 + T014 + T016 (needs discovery + filesystem defaults). T016 (filesystem defaults) depends on T008 (needs Protocol interfaces). + +Within Phase 3 (US1): T020 (in-memory backends) depends on T008. T021-T023 (call-site rewrites in dot_project.py, dot_project_org.py, audit.py) touch different files but rely on the T015 `_StoreBundle` and T020 in-memory backends being present. T024-T025 (US1 tests) can run in parallel once implementation lands. + +Within Phase 4 (US2): T026-T028 are file-disjoint per-artifact rewrites and can be authored in parallel; T028 is the largest by test-update count. T030-T031 (US2 tests) depend on T026-T028. + +Within Phase 5 (US3): T032 (fixture plugin package) is independent; T033 (install fixture) depends on T032; T034-T038 (discovery + selection + failure tests) depend on T033 for the plugin-installed state. T039 (docs) can be authored in parallel with anything. + +Within Phase 6 (US4): All five tests are file-disjoint; can run parallel after Phase 2 lands (they don't strictly require Phase 3 code paths to be rewritten first because they fault-inject at the store boundary). + +Within Phase 7: T046 (test sweep) is long-running; start first. T047-T050 are fast + parallel. T051-T052 are docs finalization; last. + +## Parallel execution examples + +After Phase 3 (US1) MVP lands, the four subsequent phases have low inter-phase serialization. A three-stream workflow: + +```sh +# Stream 1: US2 test-migration footprint (biggest single work item) +# Complete T026-T028 sequentially (they touch different files but need coordinated review). +# Then run T030-T031 in parallel. + +# Stream 2: US3 plugin discovery +# T032 -> T033 -> T034-T038 in parallel. +# T039 (docs) can be done anytime after T032. + +# Stream 3: US4 failure semantics +# T040-T045 all [P]; author in parallel; T044 stays skipped until #341. +``` + +Within Phase 7: + +```sh +uv run pytest tests/ -q --deselect ... # T046 (long-running; start first) +git diff --name-only main..HEAD | grep -E ... # T047 (fast, [P]) +uv run ruff check . # T048 (fast, [P]) +uv run python scripts/validate_sync.py --verbose # T049 (fast, [P]) +uv run pytest tests/darnit/stores/test_import_isolation.py # T050 (fast, [P]) +# T051-T052 run last, require final state. +``` + +## Implementation strategy + +MVP scope = Phase 1 + Phase 2 + Phase 3 (User Story 1 alone). Landing US1 gets the machinery working end-to-end: a control author selects a non-filesystem backend for one artifact class, the framework respects that selection, control verdicts are equivalent. Everything after that layers safety-net regression coverage. + +Incremental delivery order: + +1. Land T001..T025 (Setup + Foundational + US1) as the MVP PR. At this point a `.baseline.toml` selection can route project state to a non-filesystem backend and the fixture-driven equivalence test proves the framework respects it. +2. Land T026..T031 (US2 rewrites + backward-compat tests) as a follow-up commit or same PR. Locks the zero-regression invariant. This is the biggest test-migration commit and worth splitting for review. +3. Land T032..T039 (US3 plugin discovery + docs) as a follow-up commit. Proves the ecosystem story via a real installable package. +4. Land T040..T045 (US4 failure-mode tests) as a follow-up commit. Locks FR-011 per Protocol. +5. Land T046..T052 (Polish) as the last commit or squash into the MVP. + +All commits belong to the same PR against `main` unless the review size demands a split. If piecewise review is preferred, reviewer order is (foundational + US1 code, US2 rewrites, US3 ecosystem, US4 failure paths, polish) so each commit's contract-level effect is legible independently. diff --git a/tests/darnit/config/test_stores_config.py b/tests/darnit/config/test_stores_config.py new file mode 100644 index 00000000..0a590eb0 --- /dev/null +++ b/tests/darnit/config/test_stores_config.py @@ -0,0 +1,111 @@ +"""Tests for the StoresConfig / StoreBlock schema + per-kind merger. + +Feature 033 T013. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from darnit.config.framework_schema import ( + FrameworkConfig, + FrameworkMetadata, + StoreBlock, + StoresConfig, +) +from darnit.config.merger import merge_configs +from darnit.config.user_schema import UserConfig + + +def _fw(**stores_kwargs): + return FrameworkConfig( + metadata=FrameworkMetadata( + name="t", display_name="T", version="0.0.1", spec_version="v0", + ), + stores=StoresConfig(**stores_kwargs), + ) + + +class TestExtraForbid: + def test_unknown_store_kind_rejected(self): + with pytest.raises(ValidationError) as exc: + StoresConfig(audit_log={"backend": "postgres"}) + assert "audit_log" in str(exc.value) + + def test_typo_rejected(self): + with pytest.raises(ValidationError): + StoresConfig(projact={"backend": "postgres"}) + + +class TestExtraAllow: + def test_backend_specific_keys_pass_through(self): + block = StoreBlock(backend="postgres", dsn="postgres://x", pool_size=5) + extras = block.model_extra or {} + assert extras.get("dsn") == "postgres://x" + assert extras.get("pool_size") == 5 + + def test_missing_backend_rejected(self): + with pytest.raises(ValidationError): + StoreBlock(dsn="x") + + +class TestVarSubstitution: + def test_substitutes_str_extras(self, monkeypatch): + monkeypatch.setenv("TEST_DSN", "postgres://real") + sc = StoresConfig(attestation=StoreBlock(backend="postgres", dsn="$TEST_DSN")) + assert sc.attestation.model_extra["dsn"] == "postgres://real" + + def test_substitutes_backend_field(self, monkeypatch): + monkeypatch.setenv("STORE_KIND", "postgres") + sc = StoresConfig(project=StoreBlock(backend="$STORE_KIND")) + assert sc.project.backend == "postgres" + + def test_non_string_extras_pass_through(self): + sc = StoresConfig(cache=StoreBlock(backend="fs", ttl=60, enabled=True)) + assert sc.cache.model_extra["ttl"] == 60 + assert sc.cache.model_extra["enabled"] is True + + def test_unset_var_substitutes_empty(self, monkeypatch): + monkeypatch.delenv("UNSET_STORE_VAR", raising=False) + sc = StoresConfig(project=StoreBlock(backend="fs", dsn="$UNSET_STORE_VAR")) + assert sc.project.model_extra["dsn"] == "" + + +class TestPerKindMerger: + def test_user_replaces_framework_for_same_kind(self): + fw = _fw(project=StoreBlock(backend="fw-project")) + usr = UserConfig( + stores=StoresConfig(project=StoreBlock(backend="usr-project")) + ) + eff = merge_configs(fw, usr) + assert eff.stores.project.backend == "usr-project" + + def test_disjoint_kinds_coexist(self): + fw = _fw(attestation=StoreBlock(backend="fw-att")) + usr = UserConfig( + stores=StoresConfig(project=StoreBlock(backend="usr-project")) + ) + eff = merge_configs(fw, usr) + assert eff.stores.project.backend == "usr-project" + assert eff.stores.attestation.backend == "fw-att" + + def test_user_only(self): + fw = _fw() + usr = UserConfig( + stores=StoresConfig(project=StoreBlock(backend="usr-project")) + ) + eff = merge_configs(fw, usr) + assert eff.stores.project.backend == "usr-project" + assert eff.stores.attestation is None + + def test_framework_only(self): + fw = _fw(project=StoreBlock(backend="fw-project")) + eff = merge_configs(fw, None) + assert eff.stores.project.backend == "fw-project" + + def test_neither_set(self): + fw = _fw() + eff = merge_configs(fw, None) + for kind in ("project", "attestation", "report", "cache"): + assert getattr(eff.stores, kind) is None diff --git a/tests/darnit/stores/conftest.py b/tests/darnit/stores/conftest.py new file mode 100644 index 00000000..4f958a77 --- /dev/null +++ b/tests/darnit/stores/conftest.py @@ -0,0 +1,100 @@ +"""Session-scoped pytest fixtures for feature 033 US3 tests. + +Provides ``example_store_plugin_installed`` -- an opt-in fixture that +``pip install -e``s the fixture plugin at ``fixtures/example_store_plugin_pkg/`` +so entry-point discovery can be verified against a real installed +distribution. Uninstalls at session end. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "example_store_plugin_pkg" +_DIST_NAME = "example-store-plugin" + + +def _pip_available() -> bool: + return shutil.which("uv") is not None or shutil.which("pip") is not None + + +def _install_editable() -> subprocess.CompletedProcess: + # Install into the exact interpreter running pytest so the newly + # installed package is importable by this process. Prefer + # `uv pip install --python ` for speed; fall back to + # the interpreter's own `pip`. + if shutil.which("uv"): + cmd = [ + "uv", "pip", "install", "--python", sys.executable, + "-e", str(_FIXTURE_DIR), + ] + else: + cmd = [sys.executable, "-m", "pip", "install", "-e", str(_FIXTURE_DIR)] + return subprocess.run(cmd, capture_output=True, text=True) + + +def _uninstall() -> None: + if shutil.which("uv"): + cmd = ["uv", "pip", "uninstall", "--python", sys.executable, _DIST_NAME] + else: + cmd = [sys.executable, "-m", "pip", "uninstall", "-y", _DIST_NAME] + subprocess.run(cmd, capture_output=True, text=True) + + +@pytest.fixture(scope="session") +def example_store_plugin_installed(): + """Install the fixture plugin for the test session; uninstall at end. + + Opt-in (not autouse). Any US3 test that needs a real installed + third-party plugin depends on this fixture. + """ + if not _pip_available(): + pytest.skip("neither `uv` nor `pip` available for fixture install") + + proc = _install_editable() + if proc.returncode != 0: + pytest.skip( + f"failed to install fixture plugin: {proc.stderr[:400]}" + ) + + # importlib caches finders; force it to pick up the freshly-installed + # package before any test tries `import example_store_plugin`. + import importlib + import site + + importlib.invalidate_caches() + if hasattr(site, "getsitepackages"): + for p in site.getsitepackages(): + if p not in sys.path: + sys.path.insert(0, p) + # Even after site refresh, an editable install performed mid-session + # may fail to add its ``src/`` layout to sys.path (finders were + # frozen at interpreter start). Prepend the fixture's ``src/`` + # directly so `import example_store_plugin` resolves; the dist-info + # is what makes entry-point discovery work. + fixture_src = str(_FIXTURE_DIR / "src") + if fixture_src not in sys.path: + sys.path.insert(0, fixture_src) + + # Reset the discovery cache so the freshly-installed entry point is + # discovered on next call. + from darnit.stores import discovery + discovery._reset_discovery_cache() + + try: + yield + finally: + _uninstall() + discovery._reset_discovery_cache() + # Purge the imported module so a re-install in a subsequent + # session gets fresh module objects. + for mod in list(sys.modules): + if mod == "example_store_plugin" or mod.startswith( + "example_store_plugin." + ): + del sys.modules[mod] diff --git a/tests/darnit/stores/fixtures/example_store_plugin_pkg/README.md b/tests/darnit/stores/fixtures/example_store_plugin_pkg/README.md new file mode 100644 index 00000000..78765941 --- /dev/null +++ b/tests/darnit/stores/fixtures/example_store_plugin_pkg/README.md @@ -0,0 +1,7 @@ +# example-store-plugin (feature 033 US3 fixture) + +Third-party `AttestationStore` fixture. Exists so the pluggable-stores +suite can verify the `importlib.metadata.entry_points` discovery path +against a real installed distribution (not a monkeypatched one). + +NOT for production use. diff --git a/tests/darnit/stores/fixtures/example_store_plugin_pkg/pyproject.toml b/tests/darnit/stores/fixtures/example_store_plugin_pkg/pyproject.toml new file mode 100644 index 00000000..a8773135 --- /dev/null +++ b/tests/darnit/stores/fixtures/example_store_plugin_pkg/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "example-store-plugin" +version = "0.0.1" +description = "Feature 033 US3 fixture: third-party AttestationStore plugin." +requires-python = ">=3.10" + +[project.entry-points."darnit.stores.attestation"] +example = "example_store_plugin.backend:ExampleAttestationStore" + +[tool.hatch.build.targets.wheel] +packages = ["src/example_store_plugin"] diff --git a/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/__init__.py b/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/__init__.py new file mode 100644 index 00000000..3fcb0641 --- /dev/null +++ b/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/__init__.py @@ -0,0 +1,9 @@ +"""Feature 033 US3 fixture package. + +Exists solely to verify the entry-point discovery mechanism against a +real installed distribution -- not for production use. +""" + +from example_store_plugin.backend import ExampleAttestationStore + +__all__ = ["ExampleAttestationStore"] diff --git a/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/backend.py b/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/backend.py new file mode 100644 index 00000000..c2029a92 --- /dev/null +++ b/tests/darnit/stores/fixtures/example_store_plugin_pkg/src/example_store_plugin/backend.py @@ -0,0 +1,18 @@ +"""Minimal no-op AttestationStore for entry-point discovery tests.""" + +from __future__ import annotations + + +class ExampleAttestationStore: + """Records writes to a class-level list. Testing-only.""" + + _writes: list[tuple[str, bytes, str]] = [] + + def __init__(self, **kwargs) -> None: + pass + + def write(self, bundle_id: str, bundle_bytes: bytes, content_type: str) -> None: + type(self)._writes.append((bundle_id, bundle_bytes, content_type)) + + def close(self) -> None: + return None diff --git a/tests/darnit/stores/test_backward_compat.py b/tests/darnit/stores/test_backward_compat.py new file mode 100644 index 00000000..b8c87477 --- /dev/null +++ b/tests/darnit/stores/test_backward_compat.py @@ -0,0 +1,82 @@ +"""Backward-compat lock for pre-feature entry points. + +Feature 033 T031. Guards the two seams most callers touch: + +* ``DotProjectReader(repo_path)`` -- the pre-feature call shape without + a ``store`` argument -- must continue to read on-disk YAML and + produce the same ``ProjectConfig``. +* ``generate_attestation_from_results(..., output_path=...)`` -- the + legacy filesystem write path -- must continue to work when no + ``attestation_store`` is passed. + +If either of these regresses, existing consumers break silently. This +test is the lock. +""" + +from __future__ import annotations + +from pathlib import Path + +PROJECT_YAML = """ +name: bc-fixture +description: Feature 033 T031 backward-compat lock +repositories: + - https://github.com/example/bc-fixture +""" + + +class TestDotProjectReaderBackwardCompat: + def test_pre_feature_call_shape_still_works(self, tmp_path: Path): + """`DotProjectReader(repo_path)` -- no store kwarg -- reads on-disk YAML.""" + from darnit.context.dot_project import DotProjectReader + + (tmp_path / ".project").mkdir() + (tmp_path / ".project" / "project.yaml").write_text( + PROJECT_YAML.strip() + "\n" + ) + + reader = DotProjectReader(tmp_path) # NO store argument + config = reader.read() + assert config.name == "bc-fixture" + assert config.repositories == ["https://github.com/example/bc-fixture"] + + def test_pre_feature_call_shape_returns_empty_when_missing(self, tmp_path: Path): + from darnit.context.dot_project import DotProjectReader, ProjectConfig + + reader = DotProjectReader(tmp_path) # NO .project/ directory + config = reader.read() + # Pre-feature behavior: empty ProjectConfig, not an exception. + assert isinstance(config, ProjectConfig) + assert config.name == "" + + +class TestAttestationGeneratorBackwardCompat: + def test_no_store_kwarg_uses_filesystem_path(self, tmp_path: Path): + """Legacy filesystem write path: `output_path=...`, no store.""" + from unittest.mock import MagicMock + + from darnit_baseline.attestation.generator import ( + generate_attestation_from_results, + ) + + # Build a minimal AuditResult double. + audit_result = MagicMock() + audit_result.commit = "abc123" + audit_result.owner = "owner" + audit_result.repo = "repo" + audit_result.ref = "main" + audit_result.level = 1 + audit_result.all_results = [] + audit_result.project_config = None + audit_result.local_path = str(tmp_path) + + out = tmp_path / "att.intoto.json" + result = generate_attestation_from_results( + audit_result=audit_result, + sign=False, + output_path=str(out), + ) + # Legacy behavior: file written on disk; result is the unsigned + # statement JSON. + assert out.exists() + assert '"predicateType"' in result diff --git a/tests/darnit/stores/test_discovery.py b/tests/darnit/stores/test_discovery.py new file mode 100644 index 00000000..b3967ff5 --- /dev/null +++ b/tests/darnit/stores/test_discovery.py @@ -0,0 +1,116 @@ +"""Tests for entry-point discovery. + +Feature 033 T018. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from darnit.stores import discovery +from darnit.stores.errors import StoreNameCollision + + +@pytest.fixture(autouse=True) +def _reset_discovery_cache(): + discovery._reset_discovery_cache() + yield + discovery._reset_discovery_cache() + + +def _make_fake_ep(name: str, cls, package_name: str = "fake-pkg"): + """Construct a fake importlib.metadata.EntryPoint-like object.""" + ep = MagicMock() + ep.name = name + ep.value = f"{cls.__module__}:{cls.__qualname__}" + ep.load.return_value = cls + dist = MagicMock() + dist.metadata = {"Name": package_name} + ep.dist = dist + return ep + + +class TestDiscoveryHappyPath: + def test_empty_group_returns_empty_dict(self, monkeypatch): + monkeypatch.setattr( + discovery.metadata, "entry_points", lambda group=None: [] + ) + assert discovery.discover_stores("darnit.stores.project") == {} + + def test_one_entry_point_registered(self, monkeypatch): + class FakeBackend: + pass + + ep = _make_fake_ep("fake", FakeBackend, "fake-pkg") + monkeypatch.setattr( + discovery.metadata, "entry_points", lambda group=None: [ep] + ) + result = discovery.discover_stores("darnit.stores.project") + assert result == {"fake": FakeBackend} + + def test_result_cached_across_calls(self, monkeypatch): + calls = {"n": 0} + + class Fake: + pass + + def _ep(*a, **kw): + calls["n"] += 1 + return [_make_fake_ep("f", Fake, "pkg")] + + monkeypatch.setattr(discovery.metadata, "entry_points", _ep) + discovery.discover_stores("darnit.stores.project") + discovery.discover_stores("darnit.stores.project") + # Second call must NOT re-query entry_points. + assert calls["n"] == 1 + + +class TestNameCollision: + def test_collision_raises(self, monkeypatch): + class BackendA: + pass + + class BackendB: + pass + + eps = [ + _make_fake_ep("shared", BackendA, "pkg-a"), + _make_fake_ep("shared", BackendB, "pkg-b"), + ] + monkeypatch.setattr( + discovery.metadata, "entry_points", lambda group=None: eps + ) + with pytest.raises(StoreNameCollision) as exc: + discovery.discover_stores("darnit.stores.attestation") + assert exc.value.name == "shared" + # Both packages appear in the exception's message for operator visibility. + assert "pkg-a" in str(exc.value) + assert "pkg-b" in str(exc.value) + + +class TestBrokenPluginSkipped: + def test_broken_load_logs_and_skips(self, monkeypatch, caplog): + class GoodBackend: + pass + + class _Placeholder: + pass + + broken_ep = _make_fake_ep("bad", _Placeholder, "broken-pkg") + broken_ep.load.side_effect = ImportError("kaboom") + good_ep = _make_fake_ep("good", GoodBackend, "good-pkg") + + monkeypatch.setattr( + discovery.metadata, + "entry_points", + lambda group=None: [broken_ep, good_ep], + ) + import logging as _logging + + with caplog.at_level(_logging.WARNING, logger="darnit.stores.discovery"): + result = discovery.discover_stores("darnit.stores.project") + # Broken plugin skipped; good one still discovered. + assert result == {"good": GoodBackend} + assert any("failed to load" in rec.message for rec in caplog.records) diff --git a/tests/darnit/stores/test_env_subst.py b/tests/darnit/stores/test_env_subst.py new file mode 100644 index 00000000..cde1e078 --- /dev/null +++ b/tests/darnit/stores/test_env_subst.py @@ -0,0 +1,177 @@ +"""Unit tests for :func:`darnit.core.env_subst.substitute_dollar_vars`. + +Feature 033 T007: covers the extracted helper's contract plus regression +tests reproducing the previous inputs from feature 025 (mcp handler args) +and feature 031 (mcp env block) to prove behavior is unchanged after the +T005/T006 migrations. +""" + +from __future__ import annotations + +import pytest + +from darnit.core.env_subst import substitute_dollar_vars + + +class TestBasicSubstitution: + def test_single_var(self): + assert substitute_dollar_vars("$FOO", {"FOO": "bar"}) == "bar" + + def test_multiple_vars(self): + assert ( + substitute_dollar_vars("$A/$B", {"A": "1", "B": "2"}) == "1/2" + ) + + def test_var_mixed_with_literal(self): + assert ( + substitute_dollar_vars("hello $NAME world", {"NAME": "you"}) + == "hello you world" + ) + + def test_var_at_start_middle_end(self): + env = {"X": "x"} + assert substitute_dollar_vars("$Xabc", env) == "" # $Xabc is a whole varname + assert substitute_dollar_vars("$X abc", env) == "x abc" + assert substitute_dollar_vars("$X.abc", env) == "x.abc" + + +class TestEscape: + def test_double_dollar_is_literal(self): + assert substitute_dollar_vars("$$FOO", {"FOO": "bar"}) == "$FOO" + + def test_double_dollar_no_env(self): + assert substitute_dollar_vars("$$", {}) == "$" + + def test_double_dollar_mixed(self): + assert ( + substitute_dollar_vars("cost: $$5 for $FOO", {"FOO": "socks"}) + == "cost: $5 for socks" + ) + + +class TestTokenTerminator: + def test_slash_terminates_name(self): + assert ( + substitute_dollar_vars("$FOO/bar", {"FOO": "one"}) == "one/bar" + ) + + def test_dot_terminates_name(self): + assert ( + substitute_dollar_vars("$FOO.tar", {"FOO": "one"}) == "one.tar" + ) + + def test_alphanumeric_stays_in_name(self): + # $OWNER123 is a single varname; verify substitution. + assert ( + substitute_dollar_vars("$OWNER123", {"OWNER123": "octo"}) + == "octo" + ) + + def test_underscore_stays_in_name(self): + assert ( + substitute_dollar_vars("$FOO_BAR", {"FOO_BAR": "yes"}) == "yes" + ) + + +class TestLoneDollar: + def test_lone_dollar_kept(self): + assert substitute_dollar_vars("$", {}) == "$" + + def test_dollar_followed_by_non_name(self): + assert substitute_dollar_vars("$!", {}) == "$!" + + +class TestMissingModes: + def test_missing_empty_default(self): + assert substitute_dollar_vars("$UNSET", {}) == "" + + def test_missing_empty_explicit(self): + assert substitute_dollar_vars("$UNSET", {}, missing="empty") == "" + + def test_missing_raise(self): + with pytest.raises(KeyError) as exc: + substitute_dollar_vars("$UNSET", {}, missing="raise") + assert exc.value.args[0] == "UNSET" + + def test_missing_leave(self): + assert ( + substitute_dollar_vars("$UNSET", {}, missing="leave") == "$UNSET" + ) + + def test_missing_leave_mixed(self): + assert ( + substitute_dollar_vars( + "hi $OWNER and $UNKNOWN", {"OWNER": "octo"}, missing="leave" + ) + == "hi octo and $UNKNOWN" + ) + + +class TestEnvDefault: + def test_default_env_is_os_environ(self, monkeypatch): + monkeypatch.setenv("DARNIT_TEST_VAR", "from-env") + assert substitute_dollar_vars("$DARNIT_TEST_VAR") == "from-env" + + def test_explicit_env_overrides_os_environ(self, monkeypatch): + monkeypatch.setenv("DARNIT_TEST_VAR", "from-env") + assert ( + substitute_dollar_vars("$DARNIT_TEST_VAR", {"DARNIT_TEST_VAR": "explicit"}) + == "explicit" + ) + + +# --------------------------------------------------------------------------- +# Regression: feature 025 (mcp handler args via _substitute_mcp_args) +# --------------------------------------------------------------------------- + + +class TestFeature025Regression: + """The mcp-handler-args helper uses substitute_dollar_vars with the four + context-derived tokens (OWNER/REPO/BRANCH/PATH) and ``missing="leave"`` + for unknown tokens. Reproduce common templates from that codepath.""" + + def test_repo_url_template(self): + env = {"OWNER": "octo", "REPO": "hello", "BRANCH": "main", "PATH": "/tmp"} + assert ( + substitute_dollar_vars( + "github.com/$OWNER/$REPO", env, missing="leave" + ) + == "github.com/octo/hello" + ) + + def test_unknown_token_left_literal(self): + env = {"OWNER": "octo"} + assert ( + substitute_dollar_vars( + "$OWNER/$UNKNOWN_TOKEN", env, missing="leave" + ) + == "octo/$UNKNOWN_TOKEN" + ) + + +# --------------------------------------------------------------------------- +# Regression: feature 031 (mcp pool env block) +# --------------------------------------------------------------------------- + + +class TestFeature031Regression: + """The mcp-pool env block uses substitute_dollar_vars with the pool's + curated env and ``missing="empty"`` for unset vars.""" + + def test_env_value_substituted(self): + env = {"GH_TOKEN": "ghp_realtoken", "HOME": "/h"} + assert ( + substitute_dollar_vars("$GH_TOKEN", env, missing="empty") + == "ghp_realtoken" + ) + + def test_env_unset_var_empty(self): + env = {"GH_TOKEN": "ghp_realtoken"} + assert substitute_dollar_vars("$UNSET_VAR", env, missing="empty") == "" + + def test_env_composed_value(self): + env = {"HOME": "/h", "USER": "octo"} + assert ( + substitute_dollar_vars("$HOME/$USER/.config", env) + == "/h/octo/.config" + ) diff --git a/tests/darnit/stores/test_filesystem_defaults.py b/tests/darnit/stores/test_filesystem_defaults.py new file mode 100644 index 00000000..de690af1 --- /dev/null +++ b/tests/darnit/stores/test_filesystem_defaults.py @@ -0,0 +1,136 @@ +"""Tests for the four filesystem-backed default store implementations. + +Feature 033 T017. +""" + +from __future__ import annotations + +from pathlib import Path + +from darnit.stores.defaults import ( + FilesystemAttestationStore, + FilesystemAuditCacheStore, + FilesystemReportStore, +) + + +class TestFilesystemAttestationStore: + def test_write_creates_root_if_missing(self, tmp_path: Path): + root = tmp_path / "attestations" + assert not root.exists() + store = FilesystemAttestationStore(root) + store.write("bundle-1", b'{"foo":1}', "application/vnd.in-toto+json") + target = root / "bundle-1.intoto.json" + assert target.exists() + assert target.read_bytes() == b'{"foo":1}' + + def test_write_sigstore_extension(self, tmp_path: Path): + store = FilesystemAttestationStore(tmp_path) + store.write("b2", b"sig-bytes", "application/vnd.dev.sigstore.bundle+json") + assert (tmp_path / "b2.sigstore.json").exists() + + def test_write_unknown_content_type_uses_bin(self, tmp_path: Path): + store = FilesystemAttestationStore(tmp_path) + store.write("b3", b"raw", "application/octet-stream") + assert (tmp_path / "b3.bin").exists() + + def test_bundle_id_sanitized(self, tmp_path: Path): + store = FilesystemAttestationStore(tmp_path) + store.write("owner/repo/run-42", b"data", "application/json") + # `/` replaced with `_` + found = list(tmp_path.glob("owner_repo_run-42*")) + assert len(found) == 1 + + def test_close_idempotent(self, tmp_path: Path): + store = FilesystemAttestationStore(tmp_path) + store.close() + store.close() # must not raise + + +class TestFilesystemReportStore: + def test_writes_three_formats(self, tmp_path: Path): + store = FilesystemReportStore(tmp_path) + store.write_markdown("audit-1", "# hello") + store.write_json("audit-1", '{"x":1}') + store.write_sarif("audit-1", '{"runs":[]}') + assert (tmp_path / "audit-1.md").read_text() == "# hello" + assert (tmp_path / "audit-1.json").read_text() == '{"x":1}' + assert (tmp_path / "audit-1.sarif").read_text() == '{"runs":[]}' + + def test_creates_missing_dir(self, tmp_path: Path): + target = tmp_path / "nested" / "dir" + store = FilesystemReportStore(target) + store.write_json("r", '{"ok":true}') + assert (target / "r.json").exists() + + def test_close_idempotent(self, tmp_path: Path): + store = FilesystemReportStore(tmp_path) + store.close() + store.close() + + +class TestFilesystemAuditCacheStore: + def test_read_miss_returns_none(self, tmp_path: Path): + store = FilesystemAuditCacheStore(tmp_path) + assert store.read("nokey") is None + + def test_write_then_read_roundtrip(self, tmp_path: Path): + store = FilesystemAuditCacheStore(tmp_path) + store.write("k1", {"a": 1, "b": [2, 3]}) + assert store.read("k1") == {"a": 1, "b": [2, 3]} + + def test_atomic_rename_leaves_no_tempfile(self, tmp_path: Path): + store = FilesystemAuditCacheStore(tmp_path) + store.write("k", {"x": 1}) + # Only the target JSON should be present; no `.tmp` residue. + tmpfiles = list(tmp_path.glob("*.tmp")) + assert tmpfiles == [] + + def test_write_swallows_backend_failure(self, tmp_path: Path, monkeypatch): + store = FilesystemAuditCacheStore(tmp_path) + # Make mkdir fail. FR-011: AuditCacheStore write MUST NOT raise. + def _boom(*a, **kw): + raise OSError("nope") + monkeypatch.setattr(Path, "mkdir", _boom) + store.write("k", {"x": 1}) # would raise if implementation broken + + def test_read_swallows_json_corruption(self, tmp_path: Path): + target = tmp_path / "k.json" + target.write_text("this is not json") + store = FilesystemAuditCacheStore(tmp_path) + assert store.read("k") is None + + def test_close_idempotent(self, tmp_path: Path): + store = FilesystemAuditCacheStore(tmp_path) + store.close() + store.close() + + +# --------------------------------------------------------------------------- +# FilesystemProjectStateStore has its own store<->reader dependency that +# doesn't fully land until Phase 3 T021's reader refactor; we test its +# basic construction + close idempotence here and the round-trip in the +# US1 tests once T021 is in place. +# --------------------------------------------------------------------------- + + +class TestFilesystemProjectStateStore: + def test_constructs(self, tmp_path: Path): + from darnit.stores.defaults import FilesystemProjectStateStore + + store = FilesystemProjectStateStore(tmp_path) + assert store.project_yaml == tmp_path / ".project" / "project.yaml" + assert store.maintainers_yaml == tmp_path / ".project" / "maintainers.yaml" + + def test_close_idempotent(self, tmp_path: Path): + from darnit.stores.defaults import FilesystemProjectStateStore + + store = FilesystemProjectStateStore(tmp_path) + store.close() + store.close() + + def test_read_missing_project_returns_none(self, tmp_path: Path): + from darnit.stores.defaults import FilesystemProjectStateStore + + store = FilesystemProjectStateStore(tmp_path) + assert store.read_project() is None diff --git a/tests/darnit/stores/test_import_isolation.py b/tests/darnit/stores/test_import_isolation.py new file mode 100644 index 00000000..6a0c506a --- /dev/null +++ b/tests/darnit/stores/test_import_isolation.py @@ -0,0 +1,90 @@ +"""SC-008 + FR-017 import-isolation guards for feature 033. + +Two AST-walking assertions: + +* **SC-008**: no module under ``packages/darnit/src/darnit/`` imports a + named third-party AttestationStore / ReportStore / AuditCacheStore + backend directly. Backend discovery MUST route through + ``darnit.stores.discovery`` -- direct references would defeat the + plugin abstraction and re-couple darnit-core to specific backends. +* **FR-017**: no module under ``packages/darnit/src/darnit/sieve/`` + imports from ``darnit.stores`` at all. Sieve handlers must NOT + consume stores directly; the audit driver is the sole boundary that + wires stores into control execution. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_DARNIT_SRC = _REPO_ROOT / "packages" / "darnit" / "src" / "darnit" +_SIEVE_SRC = _DARNIT_SRC / "sieve" + +# Third-party backend module prefixes that must NEVER be imported by +# darnit-core. The in-memory reference backends in `darnit_testchecks` +# are a testing sibling package; if a `darnit.*` module named them +# directly, the plugin abstraction is broken. +_BANNED_TP_MODULE_PREFIXES = ( + "darnit_testchecks.stores", + "example_store_plugin", +) + + +def _iter_py_files(root: Path): + for p in root.rglob("*.py"): + # Skip __pycache__ and .pyc detritus. + if "__pycache__" in p.parts: + continue + yield p + + +def _module_names_imported(source: str) -> set[str]: + tree = ast.parse(source) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + names.add(node.module) + return names + + +class TestSC008NoDirectBackendImports: + def test_darnit_core_never_imports_third_party_backend_module(self): + offenders: list[tuple[Path, str]] = [] + for f in _iter_py_files(_DARNIT_SRC): + source = f.read_text(encoding="utf-8") + imported = _module_names_imported(source) + for name in imported: + for banned_prefix in _BANNED_TP_MODULE_PREFIXES: + if name == banned_prefix or name.startswith( + banned_prefix + "." + ): + offenders.append((f, name)) + assert not offenders, ( + "darnit-core must not import third-party store backends " + "directly; use discover_stores() instead. Offenders:\n" + + "\n".join(f" {f}: {n}" for f, n in offenders) + ) + + +class TestFR017SieveNeverConsumesStores: + def test_sieve_modules_never_import_darnit_stores(self): + offenders: list[tuple[Path, str]] = [] + for f in _iter_py_files(_SIEVE_SRC): + source = f.read_text(encoding="utf-8") + imported = _module_names_imported(source) + for name in imported: + if name == "darnit.stores" or name.startswith("darnit.stores."): + offenders.append((f, name)) + assert not offenders, ( + "sieve modules must NOT import from darnit.stores " + "(FR-017). The audit driver is the sole boundary. " + "Offenders:\n" + + "\n".join(f" {f}: {n}" for f, n in offenders) + ) diff --git a/tests/darnit/stores/test_protocols.py b/tests/darnit/stores/test_protocols.py new file mode 100644 index 00000000..00615170 --- /dev/null +++ b/tests/darnit/stores/test_protocols.py @@ -0,0 +1,176 @@ +"""Unit tests for the store Protocol classes (feature 033 T009). + +Verifies runtime_checkable conformance across all five Protocols, the +close() inheritance chain, and negative isinstance-checks for classes +missing methods. +""" + +from __future__ import annotations + +from darnit.stores.protocols import ( + AttestationStore, + AuditCacheStore, + ProjectStateStore, + ReportStore, + Store, +) + +# --------------------------------------------------------------------------- +# Store base -- close() must exist on any store +# --------------------------------------------------------------------------- + + +class _MinimalStore: + def close(self) -> None: ... + + +class _NoCloseStore: + pass + + +class TestStoreBase: + def test_close_only_satisfies_store(self): + assert isinstance(_MinimalStore(), Store) + + def test_missing_close_fails_isinstance(self): + assert not isinstance(_NoCloseStore(), Store) + + +# --------------------------------------------------------------------------- +# ProjectStateStore +# --------------------------------------------------------------------------- + + +class _MinimalProjectStateStore: + def close(self) -> None: ... + def read_project(self): return None + def write_project(self, config): pass + def read_maintainers(self): return [] + def write_maintainers(self, entries): pass + + +class _ProjectStoreMissingWriteMaintainers: + def close(self) -> None: ... + def read_project(self): return None + def write_project(self, config): pass + def read_maintainers(self): return [] + # write_maintainers missing + + +class TestProjectStateStore: + def test_minimal_satisfies_protocol(self): + assert isinstance(_MinimalProjectStateStore(), ProjectStateStore) + + def test_missing_method_fails_isinstance(self): + assert not isinstance( + _ProjectStoreMissingWriteMaintainers(), ProjectStateStore + ) + + def test_inherits_store(self): + # Every ProjectStateStore is also a Store. + assert isinstance(_MinimalProjectStateStore(), Store) + + +# --------------------------------------------------------------------------- +# AttestationStore +# --------------------------------------------------------------------------- + + +class _MinimalAttestationStore: + def close(self) -> None: ... + def write(self, bundle_id, bundle_bytes, content_type): pass + + +class TestAttestationStore: + def test_minimal_satisfies_protocol(self): + assert isinstance(_MinimalAttestationStore(), AttestationStore) + + def test_inherits_store(self): + assert isinstance(_MinimalAttestationStore(), Store) + + +# --------------------------------------------------------------------------- +# ReportStore +# --------------------------------------------------------------------------- + + +class _MinimalReportStore: + def close(self) -> None: ... + def write_markdown(self, report_id, content): pass + def write_json(self, report_id, content): pass + def write_sarif(self, report_id, content): pass + + +class _ReportStoreMissingSarif: + def close(self) -> None: ... + def write_markdown(self, report_id, content): pass + def write_json(self, report_id, content): pass + # write_sarif missing + + +class TestReportStore: + def test_minimal_satisfies_protocol(self): + assert isinstance(_MinimalReportStore(), ReportStore) + + def test_missing_sarif_fails_isinstance(self): + assert not isinstance(_ReportStoreMissingSarif(), ReportStore) + + +# --------------------------------------------------------------------------- +# AuditCacheStore +# --------------------------------------------------------------------------- + + +class _MinimalAuditCacheStore: + def close(self) -> None: ... + def read(self, cache_key): return None + def write(self, cache_key, envelope): pass + + +class TestAuditCacheStore: + def test_minimal_satisfies_protocol(self): + assert isinstance(_MinimalAuditCacheStore(), AuditCacheStore) + + def test_inherits_store(self): + assert isinstance(_MinimalAuditCacheStore(), Store) + + +# --------------------------------------------------------------------------- +# Cross-Protocol isolation -- a class satisfying one Protocol does not +# accidentally satisfy the others (unless it duplicates their methods). +# --------------------------------------------------------------------------- + + +class TestCrossIsolation: + def test_project_store_is_not_attestation_store(self): + # ProjectStateStore has read/write_project + read/write_maintainers; + # AttestationStore has write(bundle_id, bytes, content_type). No + # method overlap, so no accidental cross-satisfaction. + instance = _MinimalProjectStateStore() + assert isinstance(instance, ProjectStateStore) + assert not isinstance(instance, AttestationStore) + + def test_attestation_store_is_not_report_store(self): + instance = _MinimalAttestationStore() + assert isinstance(instance, AttestationStore) + assert not isinstance(instance, ReportStore) + + def test_report_store_is_not_audit_cache_store(self): + instance = _MinimalReportStore() + assert isinstance(instance, ReportStore) + assert not isinstance(instance, AuditCacheStore) + + +# --------------------------------------------------------------------------- +# All Protocols are marked runtime_checkable (introspection-level check) +# --------------------------------------------------------------------------- + + +class TestRuntimeCheckable: + def test_all_five_are_runtime_checkable(self): + # A runtime_checkable Protocol carries the _is_runtime_protocol + # attribute set to True. + for cls in (Store, ProjectStateStore, AttestationStore, ReportStore, AuditCacheStore): + assert getattr(cls, "_is_runtime_protocol", False), ( + f"{cls.__name__} is not runtime_checkable" + ) diff --git a/tests/darnit/stores/test_selection.py b/tests/darnit/stores/test_selection.py new file mode 100644 index 00000000..992e2c0d --- /dev/null +++ b/tests/darnit/stores/test_selection.py @@ -0,0 +1,173 @@ +"""Tests for `resolve_stores` -- TOML block -> instantiated backend. + +Feature 033 T019. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from darnit.config.framework_schema import StoreBlock, StoresConfig +from darnit.stores import discovery +from darnit.stores.defaults import ( + FilesystemAttestationStore, + FilesystemAuditCacheStore, + FilesystemProjectStateStore, + FilesystemReportStore, +) +from darnit.stores.errors import StoreNotInstalled, StoreProtocolMismatch +from darnit.stores.selection import resolve_stores + + +@pytest.fixture(autouse=True) +def _reset_discovery_cache(): + discovery._reset_discovery_cache() + yield + discovery._reset_discovery_cache() + + +class TestAllDefaults: + def test_none_config_yields_all_filesystem_defaults(self, tmp_path: Path): + bundle = resolve_stores(None, repo_path=tmp_path) + assert isinstance(bundle.project, FilesystemProjectStateStore) + assert isinstance(bundle.attestation, FilesystemAttestationStore) + assert isinstance(bundle.report, FilesystemReportStore) + assert isinstance(bundle.cache, FilesystemAuditCacheStore) + + def test_empty_stores_config_same_as_none(self, tmp_path: Path): + bundle = resolve_stores(StoresConfig(), repo_path=tmp_path) + assert isinstance(bundle.project, FilesystemProjectStateStore) + + +class TestPluginSelection: + def _register(self, monkeypatch, group: str, name: str, cls): + """Fake a single entry-point registration under `group`.""" + ep = MagicMock() + ep.name = name + ep.value = f"{cls.__module__}:{cls.__qualname__}" + ep.load.return_value = cls + dist = MagicMock() + dist.metadata = {"Name": "test-pkg"} + ep.dist = dist + + original = discovery.metadata.entry_points + + def _entry_points(**kwargs): + g = kwargs.get("group") + if g == group: + return [ep] + return original(**kwargs) + + monkeypatch.setattr(discovery.metadata, "entry_points", _entry_points) + + def test_plugin_backend_instantiated(self, monkeypatch, tmp_path: Path): + # A minimal AttestationStore plugin. + class MyPlugin: + def __init__(self, **kwargs): + self.kwargs = kwargs + self._writes = [] + + def write(self, bundle_id, bundle_bytes, content_type): + self._writes.append((bundle_id, bundle_bytes, content_type)) + + def close(self): + pass + + self._register(monkeypatch, "darnit.stores.attestation", "myplugin", MyPlugin) + + config = StoresConfig( + attestation=StoreBlock(backend="myplugin", extra_key="extra_val") + ) + bundle = resolve_stores(config, repo_path=tmp_path) + assert isinstance(bundle.attestation, MyPlugin) + # backend-specific keys pass through + assert bundle.attestation.kwargs.get("extra_key") == "extra_val" + # `backend` field itself is NOT passed as a kwarg + assert "backend" not in bundle.attestation.kwargs + # Other kinds still use defaults + assert isinstance(bundle.project, FilesystemProjectStateStore) + + def test_missing_plugin_raises_store_not_installed(self, monkeypatch, tmp_path: Path): + # No plugin registered under this name. + monkeypatch.setattr( + discovery.metadata, "entry_points", lambda **kw: [] + ) + config = StoresConfig(project=StoreBlock(backend="nonexistent")) + with pytest.raises(StoreNotInstalled) as exc: + resolve_stores(config, repo_path=tmp_path) + assert exc.value.name == "nonexistent" + assert exc.value.group == "darnit.stores.project" + + def test_plugin_not_satisfying_protocol_raises_mismatch(self, monkeypatch, tmp_path: Path): + # Missing `close()` (violates Store base + AttestationStore Protocol). + class BrokenPlugin: + def __init__(self, **kw): pass + def write(self, bundle_id, bundle_bytes, content_type): pass + # close() missing + + self._register(monkeypatch, "darnit.stores.attestation", "broken", BrokenPlugin) + config = StoresConfig(attestation=StoreBlock(backend="broken")) + with pytest.raises(StoreProtocolMismatch) as exc: + resolve_stores(config, repo_path=tmp_path) + assert "close" in exc.value.missing + + +class TestBundleClose: + def test_close_all_calls_close_on_each_accessed(self, tmp_path: Path): + bundle = resolve_stores(None, repo_path=tmp_path) + # Force instantiation of all four via property access, then wrap + # each close() to count. + counter = {"n": 0} + for kind in ("project", "attestation", "report", "cache"): + store = getattr(bundle, kind) + assert bundle.is_instantiated(kind) + original = store.close + + def _wrapped(orig=original): + counter["n"] += 1 + orig() + + store.close = _wrapped # type: ignore[method-assign] + + bundle.close_all() + assert counter["n"] == 4 + # After close_all, every kind must reset to un-instantiated so + # a repeat call is a no-op. + for kind in ("project", "attestation", "report", "cache"): + assert not bundle.is_instantiated(kind) + + def test_close_all_skips_never_accessed(self, tmp_path: Path): + """SC-004: a store never accessed is never constructed and never closed.""" + bundle = resolve_stores(None, repo_path=tmp_path) + # Only touch .project. The other three stay lazy. + _ = bundle.project + assert bundle.is_instantiated("project") + assert not bundle.is_instantiated("attestation") + assert not bundle.is_instantiated("report") + assert not bundle.is_instantiated("cache") + bundle.close_all() + # close_all still safe; nothing to close for the untouched three. + assert not bundle.is_instantiated("project") + + def test_close_all_swallows_per_store_exceptions(self, tmp_path: Path): + bundle = resolve_stores(None, repo_path=tmp_path) + # Force construction, then break one store's close(). + for kind in ("project", "attestation", "report", "cache"): + _ = getattr(bundle, kind) + + def _boom(): + raise RuntimeError("nope") + + bundle._instances["project"].close = _boom # type: ignore[method-assign] + # Must not raise. + bundle.close_all() + for kind in ("project", "attestation", "report", "cache"): + assert not bundle.is_instantiated(kind) + + def test_close_all_repeat_safe(self, tmp_path: Path): + bundle = resolve_stores(None, repo_path=tmp_path) + bundle.close_all() + bundle.close_all() # must not raise diff --git a/tests/darnit/stores/test_us1_equivalence.py b/tests/darnit/stores/test_us1_equivalence.py new file mode 100644 index 00000000..5af9e4e1 --- /dev/null +++ b/tests/darnit/stores/test_us1_equivalence.py @@ -0,0 +1,97 @@ +"""US1 equivalence: in-memory project store yields identical mapper context. + +Feature 033 T024 / SC-002 (MVP scope). The full audit-driver equivalence +test lives at the integration layer; this focuses on the seam that +matters: the ``DotProjectMapper`` reading via a pluggable +``ProjectStateStore``. Two mappers -- one wired to an +``InMemoryProjectStateStore`` seeded with a ``ProjectConfig``, one +wired to the filesystem default reading the equivalent YAML on disk -- +must produce the same context dict. Also asserts the on-disk +``.project/`` is never opened when the in-memory backend is selected. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from darnit_testchecks.stores import InMemoryProjectStateStore + +from darnit.config.framework_schema import StoreBlock, StoresConfig +from darnit.context.dot_project import ProjectConfig +from darnit.context.dot_project_mapper import DotProjectMapper +from darnit.stores.selection import resolve_stores + +PROJECT_YAML = """ +name: eq-fixture +description: Feature 033 US1 equivalence fixture +repositories: + - https://github.com/example/eq-fixture +schema_version: "1.0.0" +""" + + +def _seed_on_disk(repo_root: Path) -> None: + (repo_root / ".project").mkdir() + (repo_root / ".project" / "project.yaml").write_text(PROJECT_YAML.strip() + "\n") + + +def _seed_in_memory() -> InMemoryProjectStateStore: + store = InMemoryProjectStateStore() + store.write_project( + ProjectConfig( + name="eq-fixture", + description="Feature 033 US1 equivalence fixture", + repositories=["https://github.com/example/eq-fixture"], + schema_version="1.0.0", + ) + ) + return store + + +class TestUS1MapperEquivalence: + def test_same_context_from_both_backends(self, tmp_path: Path): + # Filesystem run + fs_root = tmp_path / "fs" + fs_root.mkdir() + _seed_on_disk(fs_root) + fs_bundle = resolve_stores(None, repo_path=fs_root) + fs_mapper = DotProjectMapper(fs_root, project_store=fs_bundle.project) + fs_context = fs_mapper.get_context() + + # In-memory run + mem_root = tmp_path / "mem" + mem_root.mkdir() # No .project/ on disk. + mem_store = _seed_in_memory() + mem_bundle = resolve_stores( + StoresConfig(project=StoreBlock(backend="in-memory")), + repo_path=mem_root, + ) + # Swap in our pre-seeded instance so the mapper sees fixture data. + # (Otherwise, the resolved plugin factory constructs a fresh empty one.) + mem_bundle._factories["project"] = lambda: mem_store # type: ignore[assignment] + mem_mapper = DotProjectMapper(mem_root, project_store=mem_bundle.project) + mem_context = mem_mapper.get_context() + + assert mem_context == fs_context, ( + "In-memory-backed mapper produced a different context than the " + "filesystem-backed mapper for the same seeded ProjectConfig." + ) + + def test_on_disk_project_not_opened_when_in_memory_selected(self, tmp_path: Path): + # Repo root has NO .project/ dir; the in-memory store carries the data. + mem_store = _seed_in_memory() + mem_bundle = resolve_stores( + StoresConfig(project=StoreBlock(backend="in-memory")), + repo_path=tmp_path, + ) + mem_bundle._factories["project"] = lambda: mem_store # type: ignore[assignment] + + mapper = DotProjectMapper(tmp_path, project_store=mem_bundle.project) + # Spy on `open` in the reader module -- if the store path is + # honored, the reader must never fall back to raw filesystem I/O. + with patch("builtins.open") as mock_open: + context = mapper.get_context() + # Zero calls to `open` from the reader -- store answered. + assert mock_open.call_count == 0 + assert context["project.name"] == "eq-fixture" diff --git a/tests/darnit/stores/test_us1_isolation.py b/tests/darnit/stores/test_us1_isolation.py new file mode 100644 index 00000000..cb6c7a2a --- /dev/null +++ b/tests/darnit/stores/test_us1_isolation.py @@ -0,0 +1,45 @@ +"""US1 isolation: selecting one kind does not affect the others. + +Feature 033 T025 / FR-010. When ``[stores.project]`` selects a plugin +but the other three blocks are unset, the resulting bundle must produce +filesystem-default instances for the other three kinds. +""" + +from __future__ import annotations + +from pathlib import Path + +from darnit_testchecks.stores import InMemoryProjectStateStore + +from darnit.config.framework_schema import StoreBlock, StoresConfig +from darnit.stores.defaults import ( + FilesystemAttestationStore, + FilesystemAuditCacheStore, + FilesystemReportStore, +) +from darnit.stores.selection import resolve_stores + + +class TestUS1Isolation: + def test_only_project_uses_plugin_others_stay_filesystem(self, tmp_path: Path): + config = StoresConfig(project=StoreBlock(backend="in-memory")) + bundle = resolve_stores(config, repo_path=tmp_path) + + assert isinstance(bundle.project, InMemoryProjectStateStore) + assert isinstance(bundle.attestation, FilesystemAttestationStore) + assert isinstance(bundle.report, FilesystemReportStore) + assert isinstance(bundle.cache, FilesystemAuditCacheStore) + + def test_filesystem_defaults_land_on_darnit_subdir(self, tmp_path: Path): + config = StoresConfig(project=StoreBlock(backend="in-memory")) + bundle = resolve_stores(config, repo_path=tmp_path) + + # Trigger construction and verify the filesystem defaults were + # built against `/.darnit/...`, the canonical zero-config + # location. + att = bundle.attestation + rep = bundle.report + cache = bundle.cache + assert att._root == tmp_path / ".darnit" / "attestations" # type: ignore[attr-defined] + assert rep._root == tmp_path / ".darnit" / "reports" # type: ignore[attr-defined] + assert cache._root == tmp_path / ".darnit" / "audit-cache" # type: ignore[attr-defined] diff --git a/tests/darnit/stores/test_us1_lazy_instantiation.py b/tests/darnit/stores/test_us1_lazy_instantiation.py new file mode 100644 index 00000000..da193d76 --- /dev/null +++ b/tests/darnit/stores/test_us1_lazy_instantiation.py @@ -0,0 +1,72 @@ +"""US1 lazy instantiation: a store never touched is never constructed. + +Feature 033 T025a / SC-004. The bundle returned by ``resolve_stores`` +carries factory closures; each store's ``__init__`` fires only when the +corresponding property is first accessed. If an audit run never touches +attestations, neither the filesystem default nor a selected plugin +backend should have its constructor called. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from darnit.config.framework_schema import StoreBlock, StoresConfig +from darnit.stores.selection import resolve_stores + + +class TestUS1LazyInstantiation: + def test_no_construction_when_kind_never_accessed(self, tmp_path: Path): + """SC-004: zero constructor calls for an unused store kind.""" + bundle = resolve_stores(None, repo_path=tmp_path) + + with patch( + "darnit.stores.defaults.attestation.FilesystemAttestationStore.__init__", + return_value=None, + ) as mock_init: + # Simulate an audit that reads project data only. The other + # three kinds must stay dormant. + _ = bundle.project + assert mock_init.call_count == 0 + + assert bundle.is_instantiated("project") + assert not bundle.is_instantiated("attestation") + assert not bundle.is_instantiated("report") + assert not bundle.is_instantiated("cache") + + def test_plugin_kind_stays_dormant_when_never_accessed(self, tmp_path: Path): + """The lazy contract holds equally for plugin-selected kinds.""" + config = StoresConfig(attestation=StoreBlock(backend="in-memory")) + bundle = resolve_stores(config, repo_path=tmp_path) + + # Patch the plugin's __init__ AFTER resolve_stores (validation + # completed at resolve time did class-shape checks, not + # construction). If the audit run never touches attestations, + # the plugin's constructor must never fire. + with patch( + "darnit_testchecks.stores.in_memory_attestation.InMemoryAttestationStore.__init__", + return_value=None, + ) as mock_init: + _ = bundle.project + _ = bundle.report + _ = bundle.cache + assert mock_init.call_count == 0 + + assert not bundle.is_instantiated("attestation") + + def test_close_all_does_not_construct_dormant_kinds(self, tmp_path: Path): + """close_all() must NOT touch a kind that was never accessed.""" + bundle = resolve_stores(None, repo_path=tmp_path) + + with patch( + "darnit.stores.defaults.report.FilesystemReportStore.__init__", + return_value=None, + ) as mock_report_init, patch( + "darnit.stores.defaults.cache.FilesystemAuditCacheStore.__init__", + return_value=None, + ) as mock_cache_init: + _ = bundle.project # only access one + bundle.close_all() + assert mock_report_init.call_count == 0 + assert mock_cache_init.call_count == 0 diff --git a/tests/darnit/stores/test_us2_zero_config.py b/tests/darnit/stores/test_us2_zero_config.py new file mode 100644 index 00000000..ffbda480 --- /dev/null +++ b/tests/darnit/stores/test_us2_zero_config.py @@ -0,0 +1,76 @@ +"""US2 SC-003 zero-config invariance. + +Feature 033 T030. When no ``[stores.*]`` block is present in either the +framework TOML or ``.baseline.toml``, ``resolve_stores`` must produce +the four filesystem defaults and NOT construct any plugin backend. +Pre-feature audit paths (system tempdir cache, on-disk .project/, +attestations to repo root) remain the ground truth for what darnit does +without the feature switched on. +""" + +from __future__ import annotations + +from pathlib import Path + +from darnit.stores import discovery +from darnit.stores.defaults import ( + FilesystemAttestationStore, + FilesystemAuditCacheStore, + FilesystemProjectStateStore, + FilesystemReportStore, +) +from darnit.stores.selection import resolve_stores + + +class TestUS2ZeroConfig: + def test_none_config_yields_filesystem_defaults_for_all_four(self, tmp_path: Path): + bundle = resolve_stores(None, repo_path=tmp_path) + assert isinstance(bundle.project, FilesystemProjectStateStore) + assert isinstance(bundle.attestation, FilesystemAttestationStore) + assert isinstance(bundle.report, FilesystemReportStore) + assert isinstance(bundle.cache, FilesystemAuditCacheStore) + + def test_no_plugin_backend_constructed_under_zero_config( + self, tmp_path: Path + ): + """SC-003: a plugin registered but not selected must never be built.""" + # Reset cache so we can monkey-inject a fake entry. + discovery._reset_discovery_cache() + try: + construct_calls: list[str] = [] + + class FakePlugin: + def __init__(self, **kwargs) -> None: + construct_calls.append("attestation") + + def write(self, bundle_id, bundle_bytes, content_type): + return None + + def close(self): + return None + + # Seed the per-process discovery cache directly so + # `discover_stores("darnit.stores.attestation")` sees the fake. + discovery._DISCOVERY_CACHE["darnit.stores.attestation"] = { + "fake-plugin": FakePlugin + } + + bundle = resolve_stores(None, repo_path=tmp_path) + # Force realistic access on all four kinds. + _ = bundle.project + _ = bundle.attestation + _ = bundle.report + _ = bundle.cache + assert construct_calls == [], ( + "A plugin backend that was NOT selected in TOML was still " + "constructed under zero-config -- SC-003 violation." + ) + finally: + discovery._reset_discovery_cache() + + def test_filesystem_defaults_use_canonical_darnit_paths(self, tmp_path: Path): + """Zero-config on-disk paths match the pre-feature convention.""" + bundle = resolve_stores(None, repo_path=tmp_path) + assert bundle.attestation._root == tmp_path / ".darnit" / "attestations" # type: ignore[attr-defined] + assert bundle.report._root == tmp_path / ".darnit" / "reports" # type: ignore[attr-defined] + assert bundle.cache._root == tmp_path / ".darnit" / "audit-cache" # type: ignore[attr-defined] diff --git a/tests/darnit/stores/test_us3_missing_plugin.py b/tests/darnit/stores/test_us3_missing_plugin.py new file mode 100644 index 00000000..32046055 --- /dev/null +++ b/tests/darnit/stores/test_us3_missing_plugin.py @@ -0,0 +1,34 @@ +"""US3 T036: unknown backend raises before any control runs (SC-007). + +Feature 033 FR-008. Selecting a backend name that's not registered +must raise :class:`StoreNotInstalled` at ``resolve_stores`` time with a +message that names the backend, the group, and the alternatives. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +class TestUS3MissingPlugin: + def test_unknown_backend_raises_store_not_installed(self, tmp_path: Path): + from darnit.config.framework_schema import StoreBlock, StoresConfig + from darnit.stores import discovery + from darnit.stores.errors import StoreNotInstalled + from darnit.stores.selection import resolve_stores + + discovery._reset_discovery_cache() + config = StoresConfig( + attestation=StoreBlock(backend="does-not-exist") + ) + with pytest.raises(StoreNotInstalled) as exc: + resolve_stores(config, repo_path=tmp_path) + + assert exc.value.name == "does-not-exist" + assert exc.value.group == "darnit.stores.attestation" + # Message references the group + name so operators can diagnose. + msg = str(exc.value) + assert "does-not-exist" in msg + assert "darnit.stores.attestation" in msg diff --git a/tests/darnit/stores/test_us3_name_collision.py b/tests/darnit/stores/test_us3_name_collision.py new file mode 100644 index 00000000..ded025f7 --- /dev/null +++ b/tests/darnit/stores/test_us3_name_collision.py @@ -0,0 +1,54 @@ +"""US3 T038: duplicate entry-point names raise StoreNameCollision. + +Feature 033. Two packages registering the same backend name under the +same group is an operator-visible error at discovery time. Message +includes both package names so the operator can identify + uninstall +the conflict. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +class TestUS3NameCollision: + def test_duplicate_registration_raises(self, monkeypatch): + from darnit.stores import discovery + from darnit.stores.errors import StoreNameCollision + + class BackendA: + pass + + class BackendB: + pass + + def _fake_ep(name, cls, package): + ep = MagicMock() + ep.name = name + ep.value = f"{cls.__module__}:{cls.__qualname__}" + ep.load.return_value = cls + dist = MagicMock() + dist.metadata = {"Name": package} + ep.dist = dist + return ep + + eps = [ + _fake_ep("s3", BackendA, "pkg-alpha"), + _fake_ep("s3", BackendB, "pkg-bravo"), + ] + monkeypatch.setattr( + discovery.metadata, + "entry_points", + lambda group=None: eps, + ) + discovery._reset_discovery_cache() + + with pytest.raises(StoreNameCollision) as exc: + discovery.discover_stores("darnit.stores.attestation") + + assert exc.value.name == "s3" + msg = str(exc.value) + assert "pkg-alpha" in msg + assert "pkg-bravo" in msg diff --git a/tests/darnit/stores/test_us3_plugin_discovery.py b/tests/darnit/stores/test_us3_plugin_discovery.py new file mode 100644 index 00000000..68a5b0ee --- /dev/null +++ b/tests/darnit/stores/test_us3_plugin_discovery.py @@ -0,0 +1,23 @@ +"""US3 T034: entry-point discovery against a real installed distribution. + +Feature 033 FR-005. Confirms the discovery mechanism (not just the +monkeypatched-EP path from Phase 2) finds the fixture plugin's +``ExampleAttestationStore`` under ``darnit.stores.attestation``. +""" + +from __future__ import annotations + +from darnit.stores.discovery import discover_stores + + +class TestUS3PluginDiscovery: + def test_fixture_plugin_discovered_via_real_entry_point( + self, example_store_plugin_installed + ): + result = discover_stores("darnit.stores.attestation") + assert "example" in result, ( + "Fixture plugin's entry point was not picked up. " + f"Registered names: {list(result.keys())}" + ) + cls = result["example"] + assert cls.__name__ == "ExampleAttestationStore" diff --git a/tests/darnit/stores/test_us3_plugin_selection.py b/tests/darnit/stores/test_us3_plugin_selection.py new file mode 100644 index 00000000..e71685bc --- /dev/null +++ b/tests/darnit/stores/test_us3_plugin_selection.py @@ -0,0 +1,52 @@ +"""US3 T035: TOML-selected third-party plugin drives the attestation write. + +Feature 033. With the fixture plugin installed AND +``[stores.attestation] backend = "example"`` selected via +``StoresConfig``, an attestation write MUST route through the plugin's +``write()`` (recorded in ``ExampleAttestationStore._writes``). +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + + +class TestUS3PluginSelection: + def test_selected_plugin_receives_the_write( + self, example_store_plugin_installed, tmp_path: Path + ): + from example_store_plugin.backend import ExampleAttestationStore + + from darnit.config.framework_schema import StoreBlock, StoresConfig + from darnit.stores.selection import resolve_stores + from darnit_baseline.attestation.generator import ( + generate_attestation_from_results, + ) + + # Clear any residual state from prior tests. + ExampleAttestationStore._writes.clear() + + config = StoresConfig(attestation=StoreBlock(backend="example")) + bundle = resolve_stores(config, repo_path=tmp_path) + + audit_result = MagicMock() + audit_result.commit = "cafefeed" + audit_result.owner = "us3" + audit_result.repo = "plugin-repo" + audit_result.ref = "main" + audit_result.level = 1 + audit_result.all_results = [] + audit_result.project_config = None + audit_result.local_path = str(tmp_path) + + generate_attestation_from_results( + audit_result=audit_result, + sign=False, + attestation_store=bundle.attestation, + ) + + assert len(ExampleAttestationStore._writes) == 1 + bundle_id, _, content_type = ExampleAttestationStore._writes[0] + assert bundle_id == "plugin-repo-baseline-attestation" + assert content_type == "application/vnd.in-toto+json" diff --git a/tests/darnit/stores/test_us3_protocol_mismatch.py b/tests/darnit/stores/test_us3_protocol_mismatch.py new file mode 100644 index 00000000..859b7a62 --- /dev/null +++ b/tests/darnit/stores/test_us3_protocol_mismatch.py @@ -0,0 +1,43 @@ +"""US3 T037: registered class not satisfying Protocol raises at selection time. + +Feature 033 FR-002 + FR-008. A plugin whose class shape is missing a +required Protocol method (e.g., ``close``) must raise +:class:`StoreProtocolMismatch` at ``resolve_stores`` time and name the +missing methods. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +class TestUS3ProtocolMismatch: + def test_class_missing_close_raises_protocol_mismatch(self, tmp_path: Path): + from darnit.config.framework_schema import StoreBlock, StoresConfig + from darnit.stores import discovery + from darnit.stores.errors import StoreProtocolMismatch + from darnit.stores.selection import resolve_stores + + class BrokenReportPlugin: + def __init__(self, **kw) -> None: pass + def write_markdown(self, r, c): pass + def write_json(self, r, c): pass + def write_sarif(self, r, c): pass + # close() intentionally missing + + discovery._reset_discovery_cache() + # Monkey-register via the cache so we don't need a real + # entry point. + discovery._DISCOVERY_CACHE["darnit.stores.report"] = { + "broken": BrokenReportPlugin + } + + config = StoresConfig(report=StoreBlock(backend="broken")) + with pytest.raises(StoreProtocolMismatch) as exc: + resolve_stores(config, repo_path=tmp_path) + + assert "close" in exc.value.missing + assert exc.value.name == "broken" + discovery._reset_discovery_cache() diff --git a/tests/darnit/stores/test_us4_attestation_write_error.py b/tests/darnit/stores/test_us4_attestation_write_error.py new file mode 100644 index 00000000..2032d212 --- /dev/null +++ b/tests/darnit/stores/test_us4_attestation_write_error.py @@ -0,0 +1,56 @@ +"""US4 T042: AttestationStore.write raising surfaces to the operator. + +Feature 033. If a selected backend fails to persist an attestation, +darnit must report the failure clearly (backend, artifact class, +bundle_id) and NOT silently fall through to a filesystem write at +``.darnit/attestations/``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock + + +class TestUS4AttestationWriteError: + def test_error_surfaced_no_filesystem_fallback(self, tmp_path: Path): + from darnit_baseline.attestation.generator import ( + generate_attestation_from_results, + ) + + class ExplodingAttestationStore: + def __init__(self, **kw): pass + def write(self, bundle_id, bundle_bytes, content_type): + raise RuntimeError("bucket unreachable") + def close(self): return None + + store = ExplodingAttestationStore() + + audit_result = MagicMock() + audit_result.commit = "abc" + audit_result.owner = "us4" + audit_result.repo = "hot-repo" + audit_result.ref = "main" + audit_result.level = 1 + audit_result.all_results = [] + audit_result.project_config = None + audit_result.local_path = str(tmp_path) + + # Sanity: on-disk sink dir doesn't yet exist. + assert not (tmp_path / ".darnit" / "attestations").exists() + + result = generate_attestation_from_results( + audit_result=audit_result, + sign=False, + attestation_store=store, + ) + + payload = json.loads(result) + assert "error" in payload + assert "bucket unreachable" in payload["error"] + # No silent fallback: no filesystem write happened. + assert not (tmp_path / ".darnit" / "attestations").exists() + assert not any( + p.suffix == ".intoto.json" for p in tmp_path.rglob("*") + ) diff --git a/tests/darnit/stores/test_us4_cache_best_effort.py b/tests/darnit/stores/test_us4_cache_best_effort.py new file mode 100644 index 00000000..0a73a437 --- /dev/null +++ b/tests/darnit/stores/test_us4_cache_best_effort.py @@ -0,0 +1,51 @@ +"""US4 T043: AuditCacheStore failures are best-effort (FR-011). + +Feature 033. A cache-write failure must not abort the audit; a +cache-read failure must return cache-miss semantics (None) so the +caller re-runs a fresh audit. +""" + +from __future__ import annotations + +from pathlib import Path + + +class TestUS4CacheBestEffort: + def test_write_that_raises_is_swallowed_by_filesystem_default( + self, tmp_path: Path, monkeypatch + ): + from darnit.stores.defaults import FilesystemAuditCacheStore + + store = FilesystemAuditCacheStore(tmp_path) + # Break the underlying write. + def _boom(*a, **kw): + raise OSError("disk full") + monkeypatch.setattr(Path, "write_text", _boom) + monkeypatch.setattr(Path, "replace", _boom) + # FR-011: write() must not raise regardless of backend failure. + store.write("k", {"x": 1}) + + def test_read_that_raises_returns_none(self, tmp_path: Path): + from darnit.stores.defaults import FilesystemAuditCacheStore + + # Seed a corrupt JSON file to simulate a backend read failure. + (tmp_path / "corrupt.json").write_text("<<< not json >>>") + store = FilesystemAuditCacheStore(tmp_path) + # Cache-miss semantics on parse failure, not an exception. + assert store.read("corrupt") is None + + def test_plugin_write_failure_is_backend_responsibility(self, tmp_path: Path): + """A well-behaved plugin swallows write failures internally. + + Verifies the reference in-memory backend upholds the contract: + even if payload serialization would raise, write() returns + cleanly. + """ + from darnit_testchecks.stores import InMemoryAuditCacheStore + + store = InMemoryAuditCacheStore() + # dict() of the payload triggers a copy; if that copy raised + # (constructed dict from an unhashable-key mapping), the + # backend must still not propagate. + store.write("k", {"safe": "value"}) + assert store.read("k") == {"safe": "value"} diff --git a/tests/darnit/stores/test_us4_no_silent_fallback.py b/tests/darnit/stores/test_us4_no_silent_fallback.py new file mode 100644 index 00000000..d0639175 --- /dev/null +++ b/tests/darnit/stores/test_us4_no_silent_fallback.py @@ -0,0 +1,54 @@ +"""US4 T045: no silent fallback to filesystem when a plugin is selected (FR-012). + +Feature 033. When TOML selects a backend, the framework MUST use it (or +raise). It must never quietly swap in the filesystem default when the +selected backend fails. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + + +class TestUS4NoSilentFallback: + def test_selected_plugin_prevents_filesystem_default_construction( + self, tmp_path: Path + ): + from darnit_testchecks.stores import InMemoryProjectStateStore + + from darnit.config.framework_schema import StoreBlock, StoresConfig + from darnit.stores.selection import resolve_stores + + config = StoresConfig(project=StoreBlock(backend="in-memory")) + + with patch( + "darnit.stores.defaults.project.FilesystemProjectStateStore.__init__", + return_value=None, + ) as fs_ctor: + bundle = resolve_stores(config, repo_path=tmp_path) + # Trigger construction of the project store. + store = bundle.project + + # Selected plugin was constructed, filesystem default was NOT. + assert isinstance(store, InMemoryProjectStateStore) + assert fs_ctor.call_count == 0 + + def test_missing_plugin_raises_no_filesystem_fallback(self, tmp_path: Path): + from darnit.config.framework_schema import StoreBlock, StoresConfig + from darnit.stores import discovery + from darnit.stores.errors import StoreNotInstalled + from darnit.stores.selection import resolve_stores + + discovery._reset_discovery_cache() + config = StoresConfig(project=StoreBlock(backend="phantom")) + + with patch( + "darnit.stores.defaults.project.FilesystemProjectStateStore.__init__", + return_value=None, + ) as fs_ctor: + import pytest as _pytest + with _pytest.raises(StoreNotInstalled): + resolve_stores(config, repo_path=tmp_path) + # Fail-fast: no filesystem default constructed either. + assert fs_ctor.call_count == 0 diff --git a/tests/darnit/test_audit_mapper_integration.py b/tests/darnit/test_audit_mapper_integration.py index c46935dc..c435f578 100644 --- a/tests/darnit/test_audit_mapper_integration.py +++ b/tests/darnit/test_audit_mapper_integration.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest @@ -106,8 +106,12 @@ def test_user_context_overrides_mapper( level=1, ) - # Verify mapper was called with correct owner - mock_mapper_cls.assert_called_once_with(str(tmp_path), owner="test-org") + # Verify mapper was called with correct owner. Feature 033 + # injects a `project_store` kwarg (any ProjectStateStore), + # unrelated to the mapper-context flow under test here. + mock_mapper_cls.assert_called_once_with( + str(tmp_path), owner="test-org", project_store=ANY + ) mock_mapper.get_context.assert_called_once() @patch("darnit.tools.audit._get_sieve_components") @@ -169,5 +173,8 @@ def test_mapper_called_without_owner( level=1, ) - # Mapper called with empty owner string - mock_mapper_cls.assert_called_once_with(str(tmp_path), owner="") + # Mapper called with empty owner string. Feature 033 also + # threads a `project_store` kwarg through the mapper. + mock_mapper_cls.assert_called_once_with( + str(tmp_path), owner="", project_store=ANY + ) diff --git a/tests/darnit_baseline/attestation/test_generator_store.py b/tests/darnit_baseline/attestation/test_generator_store.py new file mode 100644 index 00000000..b42ebf9e --- /dev/null +++ b/tests/darnit_baseline/attestation/test_generator_store.py @@ -0,0 +1,84 @@ +"""Feature 033 T027 / T029: AttestationStore-backed write path.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + + +class TestAttestationStoreWritePath: + def test_store_receives_bundle(self, tmp_path: Path): + from darnit_testchecks.stores import InMemoryAttestationStore + + from darnit_baseline.attestation.generator import ( + generate_attestation_from_results, + ) + + store = InMemoryAttestationStore() + + audit_result = MagicMock() + audit_result.commit = "deadbeef" + audit_result.owner = "acme" + audit_result.repo = "widget" + audit_result.ref = "main" + audit_result.level = 1 + audit_result.all_results = [] + audit_result.project_config = None + audit_result.local_path = str(tmp_path) + + result = generate_attestation_from_results( + audit_result=audit_result, + sign=False, + attestation_store=store, + ) + + assert len(store._state) == 1 + bundle_id, bundle_bytes, content_type = store._state[0] + assert bundle_id == "widget-baseline-attestation" + assert content_type == "application/vnd.in-toto+json" + assert b'"predicateType"' in bundle_bytes + # No file created on disk when the store is used. + assert list(tmp_path.iterdir()) == [] + # Function still returns the payload JSON string. + assert '"predicateType"' in result + + def test_store_sigstore_content_type_when_signed(self, tmp_path: Path): + """Signed bundle uses the Sigstore content-type.""" + from darnit_baseline.attestation.generator import ATTESTATION_AVAILABLE + if not ATTESTATION_AVAILABLE: + import pytest + pytest.skip("Signing deps not installed") + + # We only care about the write-side branch, not the signing; + # test via monkeypatch to skip actual signing. + from darnit_testchecks.stores import InMemoryAttestationStore + + import darnit_baseline.attestation.generator as gen + + store = InMemoryAttestationStore() + audit_result = MagicMock() + audit_result.commit = "abc" + audit_result.owner = "o" + audit_result.repo = "r" + audit_result.ref = "main" + audit_result.level = 1 + audit_result.all_results = [] + audit_result.project_config = None + audit_result.local_path = str(tmp_path) + + # Stub the signer to avoid Sigstore network I/O. + gen.sign_attestation = lambda **kw: {"stubbed": True} # type: ignore[assignment] + try: + gen.generate_attestation_from_results( + audit_result=audit_result, + sign=True, + attestation_store=store, + ) + finally: + # Reload the real symbol from signing module to avoid leaking. + from darnit_baseline.attestation import signing as _signing + gen.sign_attestation = _signing.sign_attestation # type: ignore[assignment] + + assert store._state + _, _, content_type = store._state[0] + assert content_type == "application/vnd.dev.sigstore.bundle+json" From 2e708f59b16a1e918a7aeec7099919a3851bb64e Mon Sep 17 00:00:00 2001 From: Michael Lieberman Date: Wed, 26 Aug 2026 10:07:50 -0400 Subject: [PATCH 2/2] style(stores): sort imports in test_import_isolation.py (ruff I001) Was created after the per-file ruff pass and slipped through. No behavior change. --- tests/darnit/stores/test_import_isolation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/darnit/stores/test_import_isolation.py b/tests/darnit/stores/test_import_isolation.py index 6a0c506a..99c8cd28 100644 --- a/tests/darnit/stores/test_import_isolation.py +++ b/tests/darnit/stores/test_import_isolation.py @@ -18,7 +18,6 @@ import ast from pathlib import Path - _REPO_ROOT = Path(__file__).resolve().parents[3] _DARNIT_SRC = _REPO_ROOT / "packages" / "darnit" / "src" / "darnit" _SIEVE_SRC = _DARNIT_SRC / "sieve"