fix(release): boxapply validate-first + rollback-only-on-success, no leaked temp files

Rewrite apply_4r() to call sources_line(ring) first (fail-closed on invalid ring,
no stray .shadow file). Only create .rollback copy after successful validation,
leaving no temp files on failure path. Removes unused shutil import. Adds 3
regression tests: invalid_ring_no_shadow, failure_no_rollback, and fn_raise_failclosed.

6 boxapply tests + 2 repo tests (8 total) PASSING.

Ref release-rings

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-26 07:53:08 +02:00
parent 50367801f0
commit 0c195c8701
2 changed files with 38 additions and 5 deletions

View File

@ -12,7 +12,6 @@ on failure, so a bad ring never bricks the box.
from __future__ import annotations
import os
import shutil
from annuaire.model import RINGS # ring names
@ -31,21 +30,22 @@ def sources_line(ring: str) -> str:
def apply_4r(ring, target_path, apt_update_fn) -> dict:
line = sources_line(ring) # validate ring BEFORE touching any file
shadow = target_path + ".shadow"
rollback = target_path + ".rollback"
prior = None
if os.path.exists(target_path):
prior = open(target_path).read()
shutil.copy(target_path, rollback)
with open(target_path) as fh:
prior = fh.read()
with open(shadow, "w") as fh:
fh.write(sources_line(ring) + "\n")
fh.write(line + "\n")
ok = False
try:
ok = bool(apt_update_fn(shadow))
except Exception:
ok = False
if not ok:
# restore prior ring; drop the shadow
# restore the prior ring; drop the shadow — leave no stray temp files
if prior is not None:
with open(target_path, "w") as fh:
fh.write(prior)
@ -54,5 +54,9 @@ def apply_4r(ring, target_path, apt_update_fn) -> dict:
except OSError:
pass
raise ApplyError(f"apt validation failed for ring {ring!r}; restored prior")
# success: keep a rollback copy of the prior, then atomic swap
if prior is not None:
with open(rollback, "w") as fh:
fh.write(prior)
os.replace(shadow, target_path) # atomic
return {"ring": ring, "applied": True}

View File

@ -27,3 +27,32 @@ def test_apply_4r_rolls_back_on_apt_failure(tmp_path):
bx.apply_4r("internal", str(target), apt_update_fn=lambda p: False)
# prior 'published' ring preserved — box not bricked
assert "published" in target.read_text()
def test_apply_4r_invalid_ring_leaves_no_shadow(tmp_path):
target = tmp_path / "secubox-ring.list"
target.write_text("deb https://apt.secubox.in published main contrib\n")
with pytest.raises(ValueError):
bx.apply_4r("prod", str(target), apt_update_fn=lambda p: True)
assert not (tmp_path / "secubox-ring.list.shadow").exists()
assert "published" in target.read_text() # live ring untouched
def test_apply_4r_failure_leaves_no_rollback(tmp_path):
target = tmp_path / "secubox-ring.list"
target.write_text("deb https://apt.secubox.in published main contrib\n")
with pytest.raises(bx.ApplyError):
bx.apply_4r("internal", str(target), apt_update_fn=lambda p: False)
assert not (tmp_path / "secubox-ring.list.rollback").exists()
assert not (tmp_path / "secubox-ring.list.shadow").exists()
assert "published" in target.read_text()
def test_apply_4r_fn_raising_is_failclosed(tmp_path):
def boom(p):
raise RuntimeError("apt exploded")
target = tmp_path / "secubox-ring.list"
target.write_text("deb https://apt.secubox.in published main contrib\n")
with pytest.raises(bx.ApplyError):
bx.apply_4r("internal", str(target), apt_update_fn=boom)
assert "published" in target.read_text() # fail-closed, prior preserved