diff --git a/packages/secubox-profiles/api/cli.py b/packages/secubox-profiles/api/cli.py index 2e8a9281..c54e235b 100644 --- a/packages/secubox-profiles/api/cli.py +++ b/packages/secubox-profiles/api/cli.py @@ -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 diff --git a/packages/secubox-profiles/api/manifest_edit.py b/packages/secubox-profiles/api/manifest_edit.py index ec1f5b4e..57ca12f7 100644 --- a/packages/secubox-profiles/api/manifest_edit.py +++ b/packages/secubox-profiles/api/manifest_edit.py @@ -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: diff --git a/packages/secubox-profiles/api/web.py b/packages/secubox-profiles/api/web.py index 40327173..ff2e6986 100644 --- a/packages/secubox-profiles/api/web.py +++ b/packages/secubox-profiles/api/web.py @@ -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) diff --git a/packages/secubox-profiles/tests/test_cli.py b/packages/secubox-profiles/tests/test_cli.py index fd6c7abb..61d2c4f3 100644 --- a/packages/secubox-profiles/tests/test_cli.py +++ b/packages/secubox-profiles/tests/test_cli.py @@ -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() diff --git a/packages/secubox-profiles/tests/test_manifest_edit.py b/packages/secubox-profiles/tests/test_manifest_edit.py index ea98f790..43ff848e 100644 --- a/packages/secubox-profiles/tests/test_manifest_edit.py +++ b/packages/secubox-profiles/tests/test_manifest_edit.py @@ -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") diff --git a/packages/secubox-profiles/tests/test_web.py b/packages/secubox-profiles/tests/test_web.py index 8f02c499..18d88dcf 100644 --- a/packages/secubox-profiles/tests/test_web.py +++ b/packages/secubox-profiles/tests/test_web.py @@ -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 == []