Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cerberus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,9 @@ async def admin_config_stage(request: Request) -> JSONResponse:
},
)
try:
candidate = apply_updates(lifecycle.active.config, updates)
candidate = apply_updates(
lifecycle.active.config, updates, taken_versions=lifecycle.known_versions
)
except ValueError as exc:
return JSONResponse(status_code=422, content={"staged": False, "error": str(exc)})
except ValidationError as exc:
Expand Down
33 changes: 26 additions & 7 deletions src/cerberus/control/admin_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import re
import time
import uuid
from collections.abc import Iterable
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -115,18 +116,33 @@ def build_schema(config: CerberusConfig) -> dict[str, Any]:
return {"sections": [_SECTION], "fields": fields, "version": config.metadata.version}


def _next_version(current: str) -> str:
def _next_version(current: str, taken: Iterable[str] = ()) -> str:
"""The next unused ``.N`` under the current version's date.

Every version /admin/validate has seen is bound to its first checksum for
good, so numbering from the active version alone would hand a second,
different edit the number an abandoned candidate already holds — and every
later stage would collide with it too.
"""

m = _VERSION_RE.match(current)
if m is None:
raise ValueError(f"unrecognized version format: {current!r}")
return f"{m.group(1)}.{int(m.group(2)) + 1}"
prefix, highest = m.group(1), int(m.group(2))
for version in taken:
other = _VERSION_RE.match(version)
if other is not None and other.group(1) == prefix:
highest = max(highest, int(other.group(2)))
return f"{prefix}.{highest + 1}"


def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusConfig:
def apply_updates(
config: CerberusConfig, updates: dict[str, Any], *, taken_versions: Iterable[str] = ()
) -> CerberusConfig:
"""Apply an allow-listed set of edits on top of the active config and
return a new, re-validated CerberusConfig with its version auto-bumped —
never the same version string bound to two different byte-for-byte
documents (the exact drift this session found and flagged earlier)."""
return a new, re-validated CerberusConfig with its version auto-bumped past
every version in ``taken_versions`` — never the same version string bound
to two different byte-for-byte documents."""

allowed = editable_keys(config)
rejected = set(updates) - allowed
Expand All @@ -145,14 +161,17 @@ def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusCo
else:
coerced = value
_set_path(dumped, key, coerced)
except OverflowError as exc:
# int(float("inf")): JSON accepts Infinity, and it must be a 422, not a 500
raise ValueError(f"{key!r} must be a finite number") from exc
except (KeyError, TypeError) as exc:
# allow-listed keys are built from real provider names (editable_keys),
# so this only fires if a provider name itself contains "." — an
# unconstrained-but-possible config shape, not attacker input; fail
# clean rather than let the traversal crash the request
raise ValueError(f"cannot resolve editable field {key!r}: {exc}") from exc
if updates:
dumped["metadata"]["version"] = _next_version(config.metadata.version)
dumped["metadata"]["version"] = _next_version(config.metadata.version, taken_versions)
return CerberusConfig.model_validate(dumped)


Expand Down
6 changes: 6 additions & 0 deletions src/cerberus/control/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def active(self) -> ConfigDocument:
def shadow(self) -> ConfigDocument | None:
return self._shadow

@property
def known_versions(self) -> frozenset[str]:
"""Every version bound to a checksum, active or not."""

return frozenset(self._version_checksums)

def validate(self, path: str) -> ConfigDocument:
"""Validate and permanently bind a version to its first observed checksum."""

Expand Down
40 changes: 40 additions & 0 deletions tests/test_admin_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,43 @@ async def test_stage_validate_activate_loop_changes_live_behavior(env, tmp_path)

active = await client.get("/admin/config/active")
assert active.json()["providers"]["alpha"]["quota_cooldown_seconds"] == 45


def test_version_skips_numbers_already_bound(config):
taken = {"cerberus-2026-07-16.2", "cerberus-2026-07-16.3", "cerberus-2026-01-01.9"}
updated = apply_updates(config, {"providers.alpha.quota_cooldown_seconds": 5}, taken_versions=taken)
assert updated.metadata.version == "cerberus-2026-07-16.4"


def test_non_finite_number_is_a_value_error(config):
with pytest.raises(ValueError, match="finite"):
apply_updates(config, {"providers.alpha.quota_cooldown_seconds": float("inf")})


@pytest.mark.asyncio
async def test_restaging_after_an_abandoned_candidate_validates(env, tmp_path):
"""Stage, validate, change your mind, stage again: the second candidate
must get a fresh version rather than the one the first is bound to."""

raw = raw_config("cerberus-2026-07-16.1")
raw["state"] = {"path": str(tmp_path / "state.sqlite3")}
doc = load_config_document(write_config(tmp_path, "cfg.yaml", raw))
app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream))
key = "providers.alpha.quota_cooldown_seconds"
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client:
first = (await client.post("/admin/config/stage", json={"updates": {key: 100}}, headers=CSRF)).json()
assert (await client.post("/admin/validate", json={"path": first["path"]}, headers=CSRF)).status_code == 200

second = (await client.post("/admin/config/stage", json={"updates": {key: 200}}, headers=CSRF)).json()
assert second["version"] == "cerberus-2026-07-16.3"
validated = await client.post("/admin/validate", json={"path": second["path"]}, headers=CSRF)
assert validated.status_code == 200, validated.text

infinite = await client.post(
"/admin/config/stage",
content='{"updates": {"%s": Infinity}}' % key,
headers={**CSRF, "content-type": "application/json"},
)
assert infinite.status_code == 422
Loading