fix(profiles): refuse lifecycle change on protected modules + fsync manifest (ref #896)

Review follow-ups for the awake-level setter:
- reject a protected module server-side (web 409) and in the ctl (refused,
  reason=protected) — it is forced always-on anyway, so the write is spurious
  and would dirty a core manifest; mirrors set_pin's protected refusal.
- fsync manifest_edit's atomic write before rename (the manifest is source of
  truth, not a regenerable derived file) — parity with web._atomic_write.
- tests: protected refusal (web + cli), duplicate-same-key collapse, manifest
  with no trailing newline.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 19:07:18 +02:00
parent c1970decc9
commit 6e4591e397
6 changed files with 68 additions and 4 deletions

View File

@ -341,9 +341,17 @@ def _cmd_set_lifecycle(args) -> int:
# lifecycle/wake_class sont déjà bornés par argparse (choices=) + revalidés
# dans manifest_edit.set_lifecycle ; ici seul le module (positionnel libre)
# peut être inconnu.
if args.module not in manifests:
refused = {"status": "refused", "module": args.module, "reason": "unknown"}
print(json.dumps(refused) if args.json else "refusé: module inconnu",
m0 = manifests.get(args.module)
reason = None
if m0 is None:
reason = "unknown"
elif m0.protected:
# Défense en profondeur (le panel refuse déjà 409) : ne pas réécrire le
# manifeste d'un module protégé, forcé always-on de toute façon.
reason = "protected"
if reason is not None:
refused = {"status": "refused", "module": args.module, "reason": reason}
print(json.dumps(refused) if args.json else f"refusé: {reason}",
file=sys.stdout if args.json else sys.stderr)
return 3

View File

@ -46,6 +46,11 @@ def _write_atomic(path: Path, text: str) -> None:
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
# Le manifeste est source de vérité (pas un dérivé régénérable) : on
# fsync avant le rename, comme web._atomic_write, pour qu'une coupure
# ne puisse pas laisser un manifeste vide/tronqué.
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, stat.S_IMODE(orig_mode))
os.replace(tmp, path)
except BaseException:

View File

@ -651,8 +651,16 @@ def create_app() -> FastAPI:
root = _root()
mod_dir, _prof_dir, _pins_file, _active = _cli._paths(root)
manifests = _load_manifests_or_500(mod_dir)
if body.module not in manifests:
m = manifests.get(body.module)
if m is None:
raise HTTPException(status_code=404, detail=f"module inconnu: {body.module}")
# Refus STRUCTUREL comme set_pin : un module protégé est forcé always-on
# (effective_lifecycle), fixer son niveau d'éveil n'aurait aucun effet et
# ne ferait que salir un manifeste du cœur — jamais de sudo pour ça.
if m.protected:
raise HTTPException(status_code=409,
detail=f"{body.module} est protégé (toujours actif) — "
"niveau d'éveil non modifiable")
async with _apply_lock:
return await _run_setlifecycle_ctl(body.module, body.lifecycle, body.wake_class)

View File

@ -544,3 +544,15 @@ def test_set_lifecycle_rejects_bad_enum_via_argparse(lc_root):
with pytest.raises(SystemExit):
main(["--root", str(lc_root), "set-lifecycle", "yacy",
"--lifecycle", "turbo", "--wake-class", "normal", "--json"])
def test_set_lifecycle_protected_module_refused(lc_root, capsys):
(lc_root / "modules.d" / "auth.toml").write_text(
'id="auth"\ncategory="security"\nruntime="native"\nexposure="lan"\n'
'units=["secubox-auth.service"]\nlifecycle="always-on"\n')
rc = main(["--root", str(lc_root), "set-lifecycle", "auth",
"--lifecycle", "on-demand", "--wake-class", "normal", "--json"])
out = json.loads(capsys.readouterr().out)
assert rc == 3 and out["status"] == "refused" and out["reason"] == "protected"
# manifest NOT rewritten
assert 'lifecycle="always-on"' in (lc_root / "modules.d" / "auth.toml").read_text()

View File

@ -100,3 +100,25 @@ def test_preserves_file_mode(tmp_path):
p.chmod(0o644)
set_lifecycle(p, lifecycle="eager", wake_class="normal")
assert stat.S_IMODE(p.stat().st_mode) == 0o644
def test_collapses_duplicate_same_key(tmp_path):
from api.manifest_edit import set_lifecycle
p = tmp_path / "m.toml"
p.write_text('id="m"\ncategory="infra"\nruntime="native"\nexposure="lan"\nunits=["m.service"]\n'
'lifecycle = "manual"\nlifecycle = "eager"\n')
set_lifecycle(p, lifecycle="on-demand", wake_class="normal")
assert p.read_text().count("lifecycle") == 1
m = _load(p)
assert m.lifecycle == "on-demand"
def test_handles_no_trailing_newline(tmp_path):
from api.manifest_edit import set_lifecycle
p = tmp_path / "m.toml"
p.write_text('id="m"\ncategory="infra"\nruntime="native"\nexposure="lan"\nunits=["m.service"]\n'
'lifecycle = "manual"') # no trailing newline
set_lifecycle(p, lifecycle="eager", wake_class="urgent")
m = _load(p)
assert m.lifecycle == "eager" and m.wake_class == "urgent"
assert p.read_text().endswith("\n")

View File

@ -740,3 +740,12 @@ def test_lifecycle_route_unknown_module_404_and_ctl_not_called(client, monkeypat
json={"module": "fantome", "lifecycle": "on-demand", "wake_class": "normal"})
assert r.status_code == 404
assert called == []
def test_lifecycle_route_protected_409_and_ctl_not_called(client, monkeypatch):
called = []
monkeypatch.setattr(web, "_ctl_run", lambda argv, **kw: called.append(argv))
r = client.post("/api/v1/profiles/lifecycle",
json={"module": "auth", "lifecycle": "on-demand", "wake_class": "normal"})
assert r.status_code == 409
assert called == []