From 8fe081d762434292d1af310ec85279321f2442ed Mon Sep 17 00:00:00 2001 From: agent-b883b4bc188823a2 Date: Sat, 25 Apr 2026 21:09:08 +0200 Subject: [PATCH] build(agent): c3po#b883b4 iteration --- .gitignore | 21 ++++ AGENTS.md | 32 ++++++ README.md | 63 ++++++++++- buildledger/__init__.py | 43 +++++++ buildledger/__main__.py | 5 + buildledger/adapters.py | 70 ++++++++++++ buildledger/anchors.py | 28 +++++ buildledger/cli.py | 94 ++++++++++++++++ buildledger/ledger.py | 195 ++++++++++++++++++++++++++++++++ buildledger/models.py | 229 ++++++++++++++++++++++++++++++++++++++ buildledger/schemas.py | 59 ++++++++++ buildledger/sync.py | 70 ++++++++++++ pyproject.toml | 24 ++++ test.sh | 6 + tests/test_buildledger.py | 110 ++++++++++++++++++ 15 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 buildledger/__init__.py create mode 100644 buildledger/__main__.py create mode 100644 buildledger/adapters.py create mode 100644 buildledger/anchors.py create mode 100644 buildledger/cli.py create mode 100644 buildledger/ledger.py create mode 100644 buildledger/models.py create mode 100644 buildledger/schemas.py create mode 100644 buildledger/sync.py create mode 100644 pyproject.toml create mode 100755 test.sh create mode 100644 tests/test_buildledger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..384cecc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# BuildLedger Agent Guide + +## Architecture + +- `buildledger.models` defines the canonical provenance objects. +- `buildledger.ledger` stores immutable entries in SQLite and verifies chain integrity. +- `buildledger.schemas` provides a versioned schema registry over the core models. +- `buildledger.sync` emits and applies delta packets for reconnect/resync workflows. +- `buildledger.anchors` records tamper-evident head anchors. +- `buildledger.adapters` normalizes CI context into build contracts. +- `buildledger.cli` offers small operational commands for local use. + +## Tech Stack + +- Python 3.11+ +- `pydantic` for strict model validation +- `sqlite3` for local persistence +- `pytest` for tests +- `build` for packaging verification + +## Testing + +- Run `bash test.sh` from the repo root. +- `test.sh` must succeed with `pytest` and `python3 -m build`. + +## Rules + +- Keep ledger records append-only and hash-verified. +- Prefer canonical JSON serialization for hashes and sync packets. +- Keep adapters thin and deterministic. +- Add or update tests when changing model, storage, or sync logic. +- Preserve ASCII unless a file already requires otherwise. diff --git a/README.md b/README.md index c930921..73915cc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ -# idea33-buildledger-verifiable-oss +# BuildLedger -Source logic for Idea #33 \ No newline at end of file +BuildLedger is a compact provenance ledger for reproducible OSS builds. + +It provides: + +- strict build provenance models +- a versioned schema registry +- append-only SQLite ledger storage +- tamper-evident hash chaining +- delta-sync packets for partial connectivity +- anchor records for external attestation +- CI context adapters for GitHub Actions, GitLab CI, and CircleCI + +## What It Solves + +Build outputs are only useful when they can be trusted, replayed, and compared. BuildLedger captures the build contract, environment, dependency graph, artifacts, and reproducibility metadata in one validated object, then persists that object into a verifiable ledger. + +## Package Layout + +- `buildledger.models` - core domain objects +- `buildledger.schemas` - versioned registry and validation +- `buildledger.ledger` - SQLite-backed append-only log +- `buildledger.sync` - delta packet generation and application +- `buildledger.anchors` - anchor records for ledger heads +- `buildledger.adapters` - CI context normalization +- `buildledger.cli` - local operational commands + +## Quick Start + +```bash +bash test.sh +``` + +## Example + +```python +from buildledger import SQLiteLedger + +ledger = SQLiteLedger("buildledger.sqlite") +entry = ledger.append("build.contract", {"contract_id": "demo"}) +ok, issues = ledger.verify_chain() +``` + +## CLI + +```bash +python -m buildledger schema-manifest +python -m buildledger init-ledger buildledger.sqlite +python -m buildledger append-sample buildledger.sqlite +python -m buildledger verify buildledger.sqlite +``` + +## Testing + +- `bash test.sh` +- `pytest` +- `python3 -m build` + +## Status + +This repository currently implements the core ledger, schema, sync, anchor, and CI-adapter foundation. Registry publishing adapters and full distributed integrations can be added on top of this base. diff --git a/buildledger/__init__.py b/buildledger/__init__.py new file mode 100644 index 0000000..68f060e --- /dev/null +++ b/buildledger/__init__.py @@ -0,0 +1,43 @@ +"""BuildLedger core package.""" + +from .adapters import CircleCIAdapter, GitHubActionsAdapter, GitLabCIAdapter, RegistryCoordinate +from .anchors import AnchorRecord, AnchorStore +from .ledger import SQLiteLedger +from .models import ( + Artifact, + BuildContract, + BuildEnvironment, + BuildLog, + BuildObject, + DependencyEdge, + DependencyGraph, + DependencyNode, + LedgerEntry, + ReproManifest, +) +from .schemas import SchemaRegistry +from .sync import DeltaPacket, SyncCursor, prepare_delta + +__all__ = [ + "AnchorRecord", + "AnchorStore", + "Artifact", + "BuildContract", + "BuildEnvironment", + "BuildLog", + "BuildObject", + "CircleCIAdapter", + "DeltaPacket", + "DependencyEdge", + "DependencyGraph", + "DependencyNode", + "GitHubActionsAdapter", + "GitLabCIAdapter", + "LedgerEntry", + "RegistryCoordinate", + "ReproManifest", + "SchemaRegistry", + "SQLiteLedger", + "SyncCursor", + "prepare_delta", +] diff --git a/buildledger/__main__.py b/buildledger/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/buildledger/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/buildledger/adapters.py b/buildledger/adapters.py new file mode 100644 index 0000000..e1a6643 --- /dev/null +++ b/buildledger/adapters.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .models import BuildContract, BuildEnvironment, BuildObject, ReproManifest, sha256_text, canonical_json + + +@dataclass(frozen=True) +class RegistryCoordinate: + registry: str + name: str + version: str + + def canonical(self) -> str: + return f"{self.registry}/{self.name}@{self.version}" + + +class _CIAdapter: + provider: str + + def manifest_from_context(self, context: dict[str, str]) -> ReproManifest: + source_commit = context["commit"] + env_hash = sha256_text(canonical_json(context.get("environment", {}))) + inputs_hash = sha256_text(canonical_json({k: context[k] for k in sorted(context) if k != "entropy_seed"})) + score = float(context.get("reproducibility_score", "1.0")) + return ReproManifest( + source_commit=source_commit, + deterministic_inputs_hash=inputs_hash, + toolchain_versions={"ci": self.provider}, + env_hash=env_hash, + entropy_seed=context.get("entropy_seed"), + reproducibility_score=score, + run_count=int(context.get("run_count", "1")), + matched_artifact_bytes=int(context.get("matched_artifact_bytes", "0")), + total_artifact_bytes=int(context.get("total_artifact_bytes", "0")), + ) + + def contract_from_context(self, context: dict[str, str]) -> BuildContract: + manifest = self.manifest_from_context(context) + build_object = BuildObject( + source_repo=context["repo"], + commit=context["commit"], + builder=context.get("builder", self.provider), + target=context.get("target", context["repo"]), + build_tool=context.get("build_tool", self.provider), + flags=[flag for flag in context.get("flags", "").split() if flag], + ) + environment = BuildEnvironment( + platform=context.get("platform", self.provider), + operating_system=context.get("os", "unknown"), + architecture=context.get("arch", "unknown"), + toolchain={k: v for k, v in context.items() if k.startswith("toolchain.")}, + container_image=context.get("container_image"), + env_hash=manifest.env_hash, + variables={k: v for k, v in context.items() if k.startswith("env.")}, + ) + contract_id = sha256_text(canonical_json({"provider": self.provider, "repo": context["repo"], "commit": context["commit"]})) + return BuildContract(contract_id=contract_id, build_object=build_object, environment=environment, manifest=manifest) + + +class GitHubActionsAdapter(_CIAdapter): + provider = "github-actions" + + +class GitLabCIAdapter(_CIAdapter): + provider = "gitlab-ci" + + +class CircleCIAdapter(_CIAdapter): + provider = "circleci" diff --git a/buildledger/anchors.py b/buildledger/anchors.py new file mode 100644 index 0000000..47e2e90 --- /dev/null +++ b/buildledger/anchors.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .ledger import SQLiteLedger +from .models import AnchorRecord + + +@dataclass(frozen=True) +class AnchorStore: + ledger: SQLiteLedger + + def record(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> AnchorRecord: + anchored_hash = self.ledger.anchor_head(method=method, reference=reference, metadata=metadata) + return AnchorRecord(method=method, reference=reference, anchored_hash=anchored_hash, metadata=metadata or {}) + + def all(self) -> list[AnchorRecord]: + records: list[AnchorRecord] = [] + for row in self.ledger.anchors(): + records.append( + AnchorRecord( + method=row["method"], + reference=row["reference"], + anchored_hash=row["anchored_hash"], + metadata={}, + ) + ) + return records diff --git a/buildledger/cli.py b/buildledger/cli.py new file mode 100644 index 0000000..69c292d --- /dev/null +++ b/buildledger/cli.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .adapters import GitHubActionsAdapter +from .anchors import AnchorStore +from .ledger import SQLiteLedger +from .schemas import SchemaRegistry +from .sync import SyncCursor, apply_delta, prepare_delta + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="buildledger") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("schema-manifest") + + init = sub.add_parser("init-ledger") + init.add_argument("path") + + append = sub.add_parser("append-sample") + append.add_argument("path") + + verify = sub.add_parser("verify") + verify.add_argument("path") + + anchor = sub.add_parser("anchor") + anchor.add_argument("path") + anchor.add_argument("reference") + anchor.add_argument("--method", default="cloud-anchor") + + sync = sub.add_parser("sync-plan") + sync.add_argument("path") + sync.add_argument("--sequence", type=int, default=-1) + sync.add_argument("--hash", dest="entry_hash", default="" + "0" * 64) + + gh = sub.add_parser("from-github") + gh.add_argument("context_json") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "schema-manifest": + print(json.dumps(SchemaRegistry.with_defaults().manifest(), indent=2, sort_keys=True)) + return 0 + + if args.command == "init-ledger": + SQLiteLedger(Path(args.path)).close() + return 0 + + if args.command == "append-sample": + ledger = SQLiteLedger(Path(args.path)) + ledger.append("sample", {"message": "hello buildledger"}) + ledger.close() + return 0 + + if args.command == "verify": + ledger = SQLiteLedger(Path(args.path)) + ok, issues = ledger.verify_chain() + ledger.close() + print(json.dumps({"ok": ok, "issues": issues}, indent=2)) + return 0 if ok else 1 + + if args.command == "anchor": + ledger = SQLiteLedger(Path(args.path)) + ref = AnchorStore(ledger).record(args.method, args.reference) + ledger.close() + print(ref.model_dump_json(indent=2)) + return 0 + + if args.command == "sync-plan": + ledger = SQLiteLedger(Path(args.path)) + packet = prepare_delta(ledger, SyncCursor(sequence=args.sequence, entry_hash=args.entry_hash)) + ledger.close() + print(json.dumps({"base": packet.base_cursor.__dict__, "count": len(packet.entries), "target": packet.target_cursor.__dict__}, indent=2)) + return 0 + + if args.command == "from-github": + context = json.loads(Path(args.context_json).read_text(encoding="utf-8")) + contract = GitHubActionsAdapter().contract_from_context(context) + print(contract.model_dump_json(indent=2)) + return 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/buildledger/ledger.py b/buildledger/ledger.py new file mode 100644 index 0000000..5792be0 --- /dev/null +++ b/buildledger/ledger.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .models import LedgerEntry, canonical_json, sha256_text + + +GENESIS_HASH = "0" * 64 + + +class LedgerError(RuntimeError): + pass + + +class ChainDivergenceError(LedgerError): + pass + + +class SQLiteLedger: + def __init__(self, path: str | Path = ":memory:") -> None: + self.path = str(path) + self._conn = sqlite3.connect(self.path) + self._conn.row_factory = sqlite3.Row + self._init_schema() + + def close(self) -> None: + self._conn.close() + + def __enter__(self) -> "SQLiteLedger": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def _init_schema(self) -> None: + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS entries ( + sequence INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + previous_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL UNIQUE, + anchor_ref TEXT + ) + """ + ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS anchors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + method TEXT NOT NULL, + reference TEXT NOT NULL, + anchored_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + metadata TEXT NOT NULL + ) + """ + ) + self._conn.commit() + + @property + def head(self) -> LedgerEntry | None: + row = self._conn.execute("SELECT * FROM entries ORDER BY sequence DESC LIMIT 1").fetchone() + return self._row_to_entry(row) if row else None + + def append(self, kind: str, payload: dict[str, Any], anchor_ref: str | None = None) -> LedgerEntry: + last = self.head + sequence = 0 if last is None else last.sequence + 1 + previous_hash = GENESIS_HASH if last is None else last.entry_hash + entry = LedgerEntry.create(sequence=sequence, kind=kind, payload=payload, previous_hash=previous_hash, anchor_ref=anchor_ref) + self._conn.execute( + "INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + entry.sequence, + entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), + entry.kind, + canonical_json(entry.payload), + entry.previous_hash, + entry.entry_hash, + entry.anchor_ref, + ), + ) + self._conn.commit() + return entry + + def all_entries(self) -> list[LedgerEntry]: + rows = self._conn.execute("SELECT * FROM entries ORDER BY sequence ASC").fetchall() + return [self._row_to_entry(row) for row in rows] + + def entry_at(self, sequence: int) -> LedgerEntry | None: + row = self._conn.execute("SELECT * FROM entries WHERE sequence = ?", (sequence,)).fetchone() + return self._row_to_entry(row) if row else None + + def entries_after(self, sequence: int) -> list[LedgerEntry]: + rows = self._conn.execute("SELECT * FROM entries WHERE sequence > ? ORDER BY sequence ASC", (sequence,)).fetchall() + return [self._row_to_entry(row) for row in rows] + + def verify_chain(self) -> tuple[bool, list[str]]: + issues: list[str] = [] + previous_hash = GENESIS_HASH + for expected_sequence, entry in enumerate(self.all_entries()): + if entry.sequence != expected_sequence: + issues.append(f"sequence gap at {entry.sequence}") + if entry.previous_hash != previous_hash: + issues.append(f"previous hash mismatch at {entry.sequence}") + recomputed = LedgerEntry.compute_hash( + sequence=entry.sequence, + timestamp=entry.timestamp, + kind=entry.kind, + payload=entry.payload, + previous_hash=entry.previous_hash, + ) + if recomputed != entry.entry_hash: + issues.append(f"entry hash mismatch at {entry.sequence}") + previous_hash = entry.entry_hash + return (not issues, issues) + + def anchor_head(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> str: + head = self.head + if head is None: + raise LedgerError("cannot anchor an empty ledger") + metadata_json = json.dumps(metadata or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + self._conn.execute( + "INSERT INTO anchors(method, reference, anchored_hash, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + ( + method, + reference, + head.entry_hash, + datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + metadata_json, + ), + ) + self._conn.commit() + return head.entry_hash + + def anchors(self) -> list[dict[str, Any]]: + rows = self._conn.execute("SELECT * FROM anchors ORDER BY id ASC").fetchall() + return [dict(row) for row in rows] + + def export_json(self) -> str: + return canonical_json([entry.model_dump(mode="python") for entry in self.all_entries()]) + + def import_entries(self, entries: Iterable[LedgerEntry]) -> None: + for entry in entries: + self._insert_entry(entry) + self._conn.commit() + + def _insert_entry(self, entry: LedgerEntry) -> None: + last = self.head + expected_sequence = 0 if last is None else last.sequence + 1 + expected_previous_hash = GENESIS_HASH if last is None else last.entry_hash + if entry.sequence != expected_sequence or entry.previous_hash != expected_previous_hash: + raise ChainDivergenceError("incoming entry does not extend the current chain") + recomputed = LedgerEntry.compute_hash( + sequence=entry.sequence, + timestamp=entry.timestamp, + kind=entry.kind, + payload=entry.payload, + previous_hash=entry.previous_hash, + ) + if recomputed != entry.entry_hash: + raise LedgerError("incoming entry hash is invalid") + self._conn.execute( + "INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + entry.sequence, + entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), + entry.kind, + canonical_json(entry.payload), + entry.previous_hash, + entry.entry_hash, + entry.anchor_ref, + ), + ) + + def _row_to_entry(self, row: sqlite3.Row | None) -> LedgerEntry: + if row is None: + raise LedgerError("entry not found") + payload = json.loads(row["payload"]) + timestamp = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")) + return LedgerEntry( + sequence=row["sequence"], + timestamp=timestamp, + kind=row["kind"], + payload=payload, + previous_hash=row["previous_hash"], + entry_hash=row["entry_hash"], + anchor_ref=row["anchor_ref"], + ) diff --git a/buildledger/models.py b/buildledger/models.py new file mode 100644 index 0000000..04f812e --- /dev/null +++ b/buildledger/models.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _to_jsonable(value: Any) -> Any: + if isinstance(value, BaseModel): + return _to_jsonable(value.model_dump(mode="python")) + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {str(k): _to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_to_jsonable(v) for v in value] + if isinstance(value, set): + return sorted(_to_jsonable(v) for v in value) + return value + + +def canonical_json(value: Any) -> str: + return json.dumps(_to_jsonable(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class BuildEnvironment(StrictModel): + platform: str + operating_system: str + architecture: str + toolchain: dict[str, str] + container_image: str | None = None + env_hash: str | None = None + variables: dict[str, str] = Field(default_factory=dict) + + @field_validator("toolchain") + @classmethod + def _toolchain_not_empty(cls, value: dict[str, str]) -> dict[str, str]: + if not value: + raise ValueError("toolchain must not be empty") + return value + + +class BuildObject(StrictModel): + source_repo: str + commit: str + builder: str + target: str + build_tool: str + flags: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=utcnow) + + @field_validator("commit") + @classmethod + def _commit_not_empty(cls, value: str) -> str: + if not value: + raise ValueError("commit must not be empty") + return value + + +class DependencyNode(StrictModel): + name: str + version: str + digest: str + kind: Literal["library", "tool", "plugin"] = "library" + source: str | None = None + + +class DependencyEdge(StrictModel): + source: str + target: str + relation: Literal["depends_on", "builds_with", "imports"] = "depends_on" + + +class DependencyGraph(StrictModel): + nodes: list[DependencyNode] = Field(default_factory=list) + edges: list[DependencyEdge] = Field(default_factory=list) + + +class Artifact(StrictModel): + name: str + path: str + sha256: str + size_bytes: int = Field(ge=0) + media_type: str = "application/octet-stream" + + @field_validator("sha256") + @classmethod + def _sha256_format(cls, value: str) -> str: + if len(value) != 64: + raise ValueError("sha256 must be a 64-character hex digest") + int(value, 16) + return value + + +class BuildLog(StrictModel): + stream: Literal["stdout", "stderr"] + sha256: str + line_count: int = Field(ge=0) + excerpt: list[str] = Field(default_factory=list) + + @field_validator("sha256") + @classmethod + def _sha256_format(cls, value: str) -> str: + if len(value) != 64: + raise ValueError("sha256 must be a 64-character hex digest") + int(value, 16) + return value + + +class ReproManifest(StrictModel): + source_commit: str + deterministic_inputs_hash: str + toolchain_versions: dict[str, str] = Field(default_factory=dict) + env_hash: str + entropy_seed: str | None = None + reproducibility_score: float = Field(ge=0.0, le=1.0) + run_count: int = Field(default=1, ge=1) + matched_artifact_bytes: int = Field(default=0, ge=0) + total_artifact_bytes: int = Field(default=0, ge=0) + + +class BuildContract(StrictModel): + contract_id: str + version: str = "1.0" + build_object: BuildObject + environment: BuildEnvironment + dependency_graph: DependencyGraph = Field(default_factory=DependencyGraph) + artifacts: list[Artifact] = Field(default_factory=list) + logs: list[BuildLog] = Field(default_factory=list) + manifest: ReproManifest + signature: str | None = None + notes: list[str] = Field(default_factory=list) + + def canonical_payload(self) -> dict[str, Any]: + return self.model_dump(mode="python") + + +class LedgerEntry(StrictModel): + sequence: int = Field(ge=0) + timestamp: datetime = Field(default_factory=utcnow) + kind: str + payload: dict[str, Any] + previous_hash: str + entry_hash: str + anchor_ref: str | None = None + + @field_validator("previous_hash", "entry_hash") + @classmethod + def _hash_format(cls, value: str) -> str: + if len(value) != 64: + raise ValueError("hash values must be 64-character hex digests") + int(value, 16) + return value + + @classmethod + def compute_hash( + cls, + *, + sequence: int, + timestamp: datetime, + kind: str, + payload: dict[str, Any], + previous_hash: str, + ) -> str: + material = canonical_json( + { + "sequence": sequence, + "timestamp": timestamp, + "kind": kind, + "payload": payload, + "previous_hash": previous_hash, + } + ) + return sha256_text(material) + + @classmethod + def create( + cls, + *, + sequence: int, + kind: str, + payload: dict[str, Any], + previous_hash: str, + timestamp: datetime | None = None, + anchor_ref: str | None = None, + ) -> "LedgerEntry": + ts = timestamp or utcnow() + entry_hash = cls.compute_hash( + sequence=sequence, + timestamp=ts, + kind=kind, + payload=payload, + previous_hash=previous_hash, + ) + return cls( + sequence=sequence, + timestamp=ts, + kind=kind, + payload=payload, + previous_hash=previous_hash, + entry_hash=entry_hash, + anchor_ref=anchor_ref, + ) + + +class AnchorRecord(StrictModel): + method: str + reference: str + anchored_hash: str + created_at: datetime = Field(default_factory=utcnow) + metadata: dict[str, str] = Field(default_factory=dict) diff --git a/buildledger/schemas.py b/buildledger/schemas.py new file mode 100644 index 0000000..d724cee --- /dev/null +++ b/buildledger/schemas.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, TypeVar + +from pydantic import BaseModel, ValidationError + + +T = TypeVar("T", bound=BaseModel) + + +@dataclass(frozen=True) +class SchemaKey: + name: str + version: str + + +class SchemaRegistry: + def __init__(self) -> None: + self._schemas: dict[SchemaKey, type[BaseModel]] = {} + + def register(self, name: str, version: str, model: type[T]) -> None: + self._schemas[SchemaKey(name, version)] = model + + def get(self, name: str, version: str) -> type[BaseModel]: + key = SchemaKey(name, version) + try: + return self._schemas[key] + except KeyError as exc: + raise KeyError(f"Unknown schema {name!r} version {version!r}") from exc + + def validate(self, name: str, version: str, payload: Any) -> BaseModel: + model = self.get(name, version) + return model.model_validate(payload) + + def versions(self, name: str) -> list[str]: + return sorted(key.version for key in self._schemas if key.name == name) + + def manifest(self) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for key in self._schemas: + result.setdefault(key.name, []).append(key.version) + return {name: sorted(versions) for name, versions in sorted(result.items())} + + @classmethod + def with_defaults(cls) -> "SchemaRegistry": + from .models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, LedgerEntry, ReproManifest + + registry = cls() + registry.register("BuildEnvironment", "1.0", BuildEnvironment) + registry.register("BuildObject", "1.0", BuildObject) + registry.register("DependencyNode", "1.0", DependencyNode) + registry.register("DependencyGraph", "1.0", DependencyGraph) + registry.register("Artifact", "1.0", Artifact) + registry.register("BuildLog", "1.0", BuildLog) + registry.register("ReproManifest", "1.0", ReproManifest) + registry.register("BuildContract", "1.0", BuildContract) + registry.register("LedgerEntry", "1.0", LedgerEntry) + return registry diff --git a/buildledger/sync.py b/buildledger/sync.py new file mode 100644 index 0000000..df7835d --- /dev/null +++ b/buildledger/sync.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .ledger import GENESIS_HASH, ChainDivergenceError, SQLiteLedger +from .models import LedgerEntry + + +@dataclass(frozen=True) +class SyncCursor: + sequence: int + entry_hash: str + + +@dataclass(frozen=True) +class DeltaPacket: + base_cursor: SyncCursor + entries: list[LedgerEntry] + target_cursor: SyncCursor + + +def prepare_delta(ledger: SQLiteLedger, peer_cursor: SyncCursor | None) -> DeltaPacket: + head = ledger.head + if head is None: + cursor = SyncCursor(sequence=-1, entry_hash=GENESIS_HASH) + return DeltaPacket(base_cursor=cursor, entries=[], target_cursor=cursor) + + if peer_cursor is None: + entries = ledger.all_entries() + return DeltaPacket( + base_cursor=SyncCursor(sequence=-1, entry_hash=GENESIS_HASH), + entries=entries, + target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash), + ) + + if peer_cursor.sequence == -1 and peer_cursor.entry_hash == GENESIS_HASH: + return DeltaPacket( + base_cursor=peer_cursor, + entries=ledger.all_entries(), + target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash), + ) + + local_anchor = ledger.entry_at(peer_cursor.sequence) + if local_anchor is None or local_anchor.entry_hash != peer_cursor.entry_hash: + raise ChainDivergenceError("peer cursor does not match this chain") + + delta = ledger.entries_after(peer_cursor.sequence) + return DeltaPacket( + base_cursor=peer_cursor, + entries=delta, + target_cursor=SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash), + ) + + +def apply_delta(ledger: SQLiteLedger, packet: DeltaPacket) -> SyncCursor: + current = ledger.head + if packet.base_cursor.sequence == -1: + if current is not None: + raise ChainDivergenceError("refusing to overwrite a non-empty ledger with a genesis delta") + else: + if current is None: + raise ChainDivergenceError("cannot apply a non-empty delta to an empty ledger without genesis state") + if current.sequence != packet.base_cursor.sequence or current.entry_hash != packet.base_cursor.entry_hash: + raise ChainDivergenceError("ledger head does not match delta base cursor") + + ledger.import_entries(packet.entries) + head = ledger.head + if head is None: + return SyncCursor(sequence=-1, entry_hash=GENESIS_HASH) + return SyncCursor(sequence=head.sequence, entry_hash=head.entry_hash) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2aea88 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea33-buildledger-verifiable-oss" +version = "0.1.0" +description = "BuildLedger captures, verifies, and syncs reproducible build provenance across registries." +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["pydantic>=2.7,<3"] + +[project.optional-dependencies] +test = ["pytest>=8", "build>=1.2"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["buildledger*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..d4bbbe3 --- /dev/null +++ b/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m pip install -e ".[test]" +pytest +python3 -m build diff --git a/tests/test_buildledger.py b/tests/test_buildledger.py new file mode 100644 index 0000000..c5fcdc8 --- /dev/null +++ b/tests/test_buildledger.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from buildledger.adapters import GitHubActionsAdapter +from buildledger.anchors import AnchorStore +from buildledger.ledger import SQLiteLedger +from buildledger.models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, ReproManifest +from buildledger.schemas import SchemaRegistry +from buildledger.sync import SyncCursor, apply_delta, prepare_delta + + +def make_contract() -> BuildContract: + build_object = BuildObject( + source_repo="https://example.com/repo.git", + commit="abc123", + builder="gha", + target="artifact.tar.gz", + build_tool="python", + flags=["--locked"], + ) + environment = BuildEnvironment( + platform="linux", + operating_system="ubuntu-24.04", + architecture="x86_64", + toolchain={"python": "3.11.8"}, + container_image="ghcr.io/example/build:1", + env_hash="f" * 64, + ) + manifest = ReproManifest( + source_commit="abc123", + deterministic_inputs_hash="a" * 64, + toolchain_versions={"python": "3.11.8"}, + env_hash="f" * 64, + reproducibility_score=1.0, + run_count=2, + matched_artifact_bytes=100, + total_artifact_bytes=100, + ) + return BuildContract( + contract_id="contract-1", + build_object=build_object, + environment=environment, + dependency_graph=DependencyGraph(nodes=[DependencyNode(name="dep", version="1.0.0", digest="d" * 64)]), + artifacts=[Artifact(name="artifact", path="dist/artifact.tar.gz", sha256="b" * 64, size_bytes=100)], + logs=[BuildLog(stream="stdout", sha256="c" * 64, line_count=1, excerpt=["ok"])], + manifest=manifest, + ) + + +def test_schema_registry_validates_contract() -> None: + registry = SchemaRegistry.with_defaults() + contract = make_contract() + loaded = registry.validate("BuildContract", "1.0", contract.model_dump(mode="python")) + assert loaded.contract_id == "contract-1" + assert registry.manifest()["BuildContract"] == ["1.0"] + + +def test_ledger_chain_and_anchor_round_trip(tmp_path: Path) -> None: + ledger = SQLiteLedger(tmp_path / "ledger.sqlite") + first = ledger.append("build.contract", make_contract().model_dump(mode="python")) + second = ledger.append("build.proof", {"status": "verified", "contract": first.entry_hash}) + + ok, issues = ledger.verify_chain() + assert ok + assert issues == [] + assert second.sequence == 1 + + anchor = AnchorStore(ledger).record("cloud-anchor", "s3://bucket/buildledger") + assert anchor.anchored_hash == second.entry_hash + assert ledger.anchors()[0]["anchored_hash"] == second.entry_hash + + +def test_delta_sync_applies_to_fresh_ledger(tmp_path: Path) -> None: + source = SQLiteLedger(tmp_path / "source.sqlite") + source.append("build.contract", make_contract().model_dump(mode="python")) + source.append("build.proof", {"status": "verified"}) + + packet = prepare_delta(source, SyncCursor(sequence=-1, entry_hash="0" * 64)) + target = SQLiteLedger(tmp_path / "target.sqlite") + cursor = apply_delta(target, packet) + + assert cursor.sequence == 1 + assert target.verify_chain()[0] + assert len(target.all_entries()) == 2 + + +def test_ci_adapter_generates_contract() -> None: + context = { + "repo": "https://example.com/repo.git", + "commit": "abc123", + "builder": "gha", + "target": "artifact.tar.gz", + "build_tool": "python", + "platform": "github-actions", + "os": "ubuntu-24.04", + "arch": "x86_64", + "toolchain.python": "3.11.8", + "env.PYTHONHASHSEED": "0", + "flags": "--locked", + "run_count": "2", + "reproducibility_score": "1.0", + } + contract = GitHubActionsAdapter().contract_from_context(context) + assert contract.build_object.commit == "abc123" + assert contract.environment.variables["env.PYTHONHASHSEED"] == "0" + assert contract.manifest.run_count == 2