diff --git a/docs/README.md b/docs/README.md index c9c9d1e..c145fbd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,14 +5,18 @@ records the single 2026-10-17 namespace failure: EXPERIMENTAL / EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED. The historical READY_FOR_APPROVAL verdict preceded that consumed attempt. The [task-limit protocol](design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md) -records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE / -OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real -manager and new EGL runtime remain NOT_RUN. Separate approval is required for -benign qualification, then for any new EGL attempt; no retry is authorized. -The first benign nominal invocation refused in real-UID accounting before -ATTEMPT or scope creation. The narrowly scoped UID count correction and its -offline/read-only validation are recorded in that protocol; native admission -and parent-death cleanup remain unqualified. +records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE. +Admission now checks inherited NPROC against a trusted host-global task upper +bound before ATTEMPT and immediately before release; the strict real-UID census +remains an optional historical diagnostic. The earlier ENOENT and process-set +change refusals are preserved. The current delta has 99 passing inert tests, +14 unchanged observer tests and one successful read-only preflight; two fresh +sealed LABs pass passive CLI planning without ATTEMPT, scope or payload. +Independent delta review is ACCEPTED; final audit is PASS / ACCEPTED, with no +blocking finding. Disposition: CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL, +source-only. Real manager admission, parent-death cleanup and new EGL runtime +remain NOT_RUN. Separate approval is required for benign qualification, then +for any new EGL attempt; no retry is authorized. [Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN, diff --git a/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md b/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md index eaff009..0381dc7 100644 --- a/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md +++ b/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md @@ -1,6 +1,8 @@ # STEP20C — EGL/FX task-limit protocol design — 2026-10-17 -Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE / OFFLINE_REVIEW_ACCEPTED**. +Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE**. Historical offline review is +accepted; independent review of the current global-headroom delta is accepted. +The final audit of this delta is **PASS / ACCEPTED**, with no blocking finding. New runtime: **NOT_RUN**. No scope, Bubblewrap, EGL/FX helper or compositor is launched by this mission. The source-only runner now implements the gate; qualification against the real manager remains separately approval-gated. @@ -77,6 +79,75 @@ manifest/fixture hashes and historical preservation. Disposition: **CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of the correction does not authorize the three future native qualification commands. +## Conservative global headroom fast path — 2026-10-17 + +The admission path replaces the fragile all-PID census with the system task +upper bound in field four (`runnable/total`) of Linux `proc_loadavg(5)`. +Every individual real UID's thread count is bounded by that host total at the +sample. The minimum finite inherited NPROC soft/hard limit must be **strictly +greater than total + 256**; infinity is retained, never replaced or raised. +Both read-only preflight (before ATTEMPT) and admission immediately before +release perform this check. Counts may change between checks: no PID-set or +loadavg equality is required, but each observation must independently leave +the full reserve. This is no atomic reservation against future task creation; +the kernel still enforces NPROC and the owned cgroup must still prove exact +finite `pids.max=256`. Mixed-UID helpers do not invalidate the upper bound. + +Trust qualification is deliberately Linux-specific and fail-closed: the exact +`/proc` path must be a unique root procfs mount, with no overmount affecting +loadavg. Initial self PID/user namespace identities (`4026531836`/`4026531837`), +the full initial UID map and a singleton self `NSpid` equal to the supervisor PID +are required. PID1 namespace links may be ptrace-protected and are not needed. These +recognized Linux identities are a conservative qualification restriction, not +a portable namespace discovery guarantee. The `/proc` root and `loadavg` must +be root-owned; process metadata such as `self/status` may legitimately be +user-owned. Writable files, symlinks, device/inode drift, malformed or oversized +data, unavailable context or any ambiguous source refuse admission. Reads use bounded no-follow FDs and +qualify the context before and after the sample. Trusted kernel/root mount +administration remains an assumption; hostile root is outside this protocol. + +`uid_threads()` remains the strict optional historical diagnostic, outside +admission. The earlier ENOENT and `process creation/end` refusals above remain +historical facts. `ProcFS.identity()`, bootstrap/manager ownership, pidfds, +barrier, cleanup and all cgroup controls retain their contracts. Offline tests +cannot establish `CGROUP_NATIVE_PASS`; real qualification remains **NOT_RUN**. + +### Current global-headroom validation — 2026-10-17 + +The inert Python suite passes **99 tests** (86 historical plus 13 new cases). +The unchanged private parent-death observer independently passes its **14 +deterministic tests**. Neither suite launches a real scope or payload. + +Exactly one real read-only preflight ran through Desktop Commander in the +ArchASP app.slice context at **2026-10-17T06:57:21.960482+00:00** +(08:57:21 CEST). It passed with inherited NPROC soft/hard **60706/60706**, +one global task sample of **1439**, reserve **256** and observed remaining +margin **59011**. These values describe that sample only; capacity is not +reserved. Only `systemctl show` child commands were allowed. No ATTEMPT, +scope or payload was created; native qualification remains **NOT_RUN**. + +Two new private 0700 LABs have distinct nonces and exact sealed source copies, +with **47 file identities / 39 ELF records each**. Their isolated passive CLI +plans pass through Desktop Commander without `launcher_error`, writes, +ATTEMPT, socket or result creation. The historical UID-correction LAB inventories +remain unchanged. Private paths, nonces, process identifiers and raw evidence +are retained outside public documentation. + +Independent delta review is **ACCEPTED**, with no blocking finding; it +independently reproduces **99/99** tests and **72 targeted tests**, with AST +invariants and Python style checks passing. The final audit is **PASS / ACCEPTED**, +with no blocking finding: it independently reproduces **99/99 + 14/14** tests, +confirms 13 sensitive symbols/classes unchanged by AST, both LAB inventories +and fixture hashes, identical historical inventories and passive CLI exit 0 / +NOT_RUN. Audited source hashes matched the reviewed-file receipt before this +factual documentation closeout. + +Disposition: **CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL**, source-only. +Native qualification remains **NOT_RUN**; no `CGROUP_NATIVE_PASS` follows. +Fresh conversational approval is required before nominal or parent-death +execution; EGL needs another fresh LAB, review and approval. STEP20C remains +NEXT and production multi-output remains NOT_YET_SUPPORTED. + ## Evidence, guarantees and admission | Route / evidence | Risk | Bound actually supplied | Admission condition / disposition | @@ -91,9 +162,10 @@ the correction does not authorize the three future native qualification commands | Closed mount/environment/device/FD policy | Dlopen drift, inherited socket or control-plane leak | Fixed exposure and sealed inputs, not total resource containment | Preserve pins, ABI, manifest closure and exact allowlists; no new host device/mount | Linux [RLIMIT_NPROC documentation](https://man7.org/linux/man-pages/man2/getrlimit.2.html) -explains the real-UID thread limit and EAGAIN at creation. A procfs scan is a -refusal diagnostic, not an atomic reservation: disappearing entries, unreadable -status, UID changes or inconsistent thread counts must be reported explicitly. +explains the real-UID thread limit and EAGAIN at creation. The retained optional +procfs scan is a strict diagnostic, not an admission prerequisite or atomic +reservation: disappearing entries, unreadable status, UID changes or inconsistent +thread counts must be reported explicitly. The [kernel cgroup v2 contract](https://docs.kernel.org/admin-guide/cgroup-v2.html#pid) counts TIDs hierarchically and rejects task creation over the limit. Migration can exceed pids.max, so a verified empty private group and controlled admission @@ -132,9 +204,9 @@ Independent code review and native qualification are separate gates: collecting agent. No cgroup mount, directory FD or user bus reaches payload. 4. After cgroup admission, omit only the explicit NPROC=256 override, retain the inherited UID soft/hard limits and all other prlimit settings. Record - inherited NPROC, real UID, diagnostic thread-count provenance and effective - cgroup properties; any known lower-than-UID-load NPROC refuses explicitly. - Count uncertainty/drift cannot be accepted as a proof of headroom. No fallback + inherited NPROC, real UID, trusted global task total, full reserve and effective + cgroup properties; minimum finite NPROC <= total + 256 refuses explicitly. + Missing or ambiguous host samples cannot prove headroom. No fallback to a larger NPROC, unlimited task count, unconfined launch or new device. 5. Reserve the new single attempt durably with O_EXCL before creating its scope/child or otherwise mutating native execution state. A collision, diff --git a/tooling/probes/step20c-egl-fx/README.md b/tooling/probes/step20c-egl-fx/README.md index ef0684e..e418bbd 100644 --- a/tooling/probes/step20c-egl-fx/README.md +++ b/tooling/probes/step20c-egl-fx/README.md @@ -17,10 +17,15 @@ nouvelle autorisation conversationnelle avec leur propre LAB et manifeste. Le premier essai bénin nominal du 17 octobre 2026 a refusé avant ATTEMPT, création de scope ou payload : `UID process divergent` dans le diagnostic `ProcFS.uid_threads()`. Les deux LABs bénins historiques restent non consommés -et ne doivent pas être réutilisés. Le comptage corrigé inclut les threads de +et ne doivent pas être réutilisés. Le diagnostic historique corrigé inclut les threads de tous les processus dont l'UID réel correspond, même avec des UID effectif, -sauvé ou filesystem différents. Il refuse toute incohérence observée ; ce -snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles +sauvé ou filesystem différents. Il reste disponible hors chemin d'admission. +L'admission lit désormais la borne système `runnable/total` de `/proc/loadavg` +après qualification conservative du procfs hôte Linux et de ses namespaces +initiaux ; les contextes ambigus refusent. Le minimum soft/hard NPROC fini doit +être strictement supérieur au total plus256, avant ATTEMPT et avant release. +Les valeurs peuvent fluctuer sans égalité entre snapshots si chaque marge reste +suffisante. Aucun comptage ne réserve atomiquement de capacité. `ProcFS.identity()` conserve ses contrôles stricts pour le superviseur, le manager et le bootstrap. Les régressions restent hors ligne ; cette correction n'établit aucun PASS cgroup natif. @@ -135,8 +140,8 @@ le plan : aucun processus, fichier ATTEMPT, socket ni appel manager. Le helper Python est chargé par chemin absolu, après vérification de son hash, même avec `python3 -I`. Avec approbation, le préflight refuse un vrai launcher en session scope, un manager non délégué, app.slice inactif, un montage non cgroup2/domain, -un ancêtre non writable, pids absent, un headroom insuffisant, un diagnostic -UID incertain ou NPROC hérité sous la charge UID. Aucun snapshot /proc ne +un ancêtre non writable, pids absent, un headroom insuffisant, une source +loadavg non fiable ou NPROC hérité <= total système +256. Aucun snapshot /proc ne constitue une réservation. Aucun processus n'est migré par le superviseur. Après ATTEMPT durable O_EXCL, systemd-run crée un seul scope et lance le diff --git a/tooling/probes/step20c-egl-fx/cgroup_gate.py b/tooling/probes/step20c-egl-fx/cgroup_gate.py index c168e56..aabc69e 100644 --- a/tooling/probes/step20c-egl-fx/cgroup_gate.py +++ b/tooling/probes/step20c-egl-fx/cgroup_gate.py @@ -151,6 +151,91 @@ class ProcFS: except (OSError, ValueError, KeyError, IndexError) as error: raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error + def bounded_text(self, relative, limit, root_owned=False): + """Lire sans suivre le fichier final ; refuser troncature et remplacement.""" + path = self.root / relative + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + before = os.fstat(descriptor) + require(stat.S_ISREG(before.st_mode) and not before.st_mode & 0o022, + "source proc modifiable/non régulière") + require(not root_owned or before.st_uid == 0, + "source proc non root") + data = os.read(descriptor, limit + 1) + require(len(data) <= limit and os.read(descriptor, 1) == b"", + "source proc trop longue") + after = path.stat(follow_symlinks=False) + require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino), + "source proc remplacée") + return data.decode("ascii") + finally: + os.close(descriptor) + + def host_context(self): + """Qualifier seulement le procfs hôte Linux reconnu, sinon refuser.""" + require(self.root == Path("/proc") and self.root.resolve() == self.root, + "source proc hors chemin hôte") + root = self.root.stat() + require(root.st_uid == 0 and not root.st_mode & 0o022, + "racine proc non fiable") + mounts = self.bounded_text("self/mountinfo", LIMIT) + matches = [] + for line in mounts.splitlines(): + left, right = line.split(" - ", 1) + fields, source = left.split(), right.split() + require(len(fields) >= 6 and len(source) == 3, + "mountinfo proc invalide") + target = fields[4] + if target == "/proc": + require(fields[3] == "/" and source[0] == "proc" + and source[1] == "proc", "montage proc hôte requis") + matches.append((fields[0], fields[2])) + else: + require(not any(target == name or target.startswith(name + "/") + for name in ("/proc/loadavg", "/proc/self", "/proc/1")), + "surmonture source proc refusée") + require(len(matches) == 1, "montage proc ambigu/absent") + require(matches[0][1] == f"{os.major(root.st_dev)}:{os.minor(root.st_dev)}", + "device proc divergent") + # WHY CONTRACT INVARIANT: recognizing Linux initial self namespace + # inodes avoids confusing two equally nested namespaces with the host. + # PID1 namespace links may be ptrace-protected for ordinary users; the + # singleton self NSpid additionally binds this procfs PID view to us. + namespaces = [] + for kind, initial in (("pid", 4026531836), ("user", 4026531837)): + link = os.readlink(self.root / "self" / "ns" / kind) + require(link == f"{kind}:[{initial}]", "namespace proc non initial") + namespaces.append(link) + uid_map = self.bounded_text("self/uid_map", 256).split() + require(uid_map == ["0", "0", "4294967295"], "UID map non initiale") + status = self.bounded_text("self/status", 65536).splitlines() + nspids = [line.split()[1:] for line in status if line.startswith("NSpid:")] + require(nspids == [[str(os.getpid())]], "vue PID proc non hôte") + loadavg = (self.root / "loadavg").stat(follow_symlinks=False) + require(loadavg.st_dev == root.st_dev, "device loadavg divergent") + return (root.st_dev, root.st_ino, tuple(matches), tuple(namespaces), + loadavg.st_ino) + + def global_tasks(self): + """Borne système ponctuelle ; aucun parcours PID ni réservation.""" + try: + before = self.host_context() + text = self.bounded_text("loadavg", 256, root_owned=True) + # WHY CONTRACT INVARIANT: bounded ASCII full-match rejects partial, + # extra and malformed fields rather than accepting an undercount. + match = re.fullmatch( + r"[0-9]{1,10}\.[0-9]{2} [0-9]{1,10}\.[0-9]{2} " + r"[0-9]{1,10}\.[0-9]{2} ([0-9]{1,10})/([0-9]{1,10}) " + r"[0-9]{1,10}\n", text) + require(match is not None, "loadavg format invalide") + runnable, total = map(int, match.groups()) + require(0 <= runnable <= total and total > 0, + "loadavg compte incohérent") + require(self.host_context() == before, "contexte proc divergent") + return total + except (OSError, UnicodeError, ValueError, IndexError) as error: + raise Refusal(f"source loadavg non fiable/illisible: {error}") from error + def nproc(self): return resource.getrlimit(resource.RLIMIT_NPROC) @@ -334,6 +419,23 @@ def capacity(cgroups, app): return records +def nproc_headroom(proc): + """Vérifier la réserve entière contre la borne système, sans muter NPROC.""" + limits = proc.nproc() + require(len(limits) == 2 and all( + isinstance(value, int) and (value == resource.RLIM_INFINITY or value >= 0) + for value in limits), "RLIMIT_NPROC invalide") + total = proc.global_tasks() + require(isinstance(total, int) and total > 0, "charge système invalide") + finite = [value for value in limits if value != resource.RLIM_INFINITY] + # WHY CONTRACT INVARIANT: the system total bounds any real UID, including + # mixed credentials. Strict > reserves TASKS at each sample, never atomically. + require(not finite or min(finite) > total + TASKS, + f"inherited RLIMIT_NPROC insuffisant : {limits}, total={total}, " + f"réserve={TASKS}") + return {"nproc": limits, "global_tasks": total, "required_headroom": TASKS} + + def preflight(proc, cgroups, manager, uid, pid, deadline): """Toutes les observations précèdent ATTEMPT et la première mutation.""" require(uid != 0 and uid == os.geteuid(), "UID réel/effectif invalide") @@ -360,13 +462,10 @@ def preflight(proc, cgroups, manager, uid, pid, deadline): controllers, enabled, writable = cgroups.available(app) require("pids" in controllers and "pids" in enabled, "contrôleur pids absent") require(writable, "ancêtre commun non writable") - limits = proc.nproc() - load = proc.uid_threads(uid) - require(all(value == resource.RLIM_INFINITY or value >= load for value in limits), - f"inherited RLIMIT_NPROC insuffisant : {limits}, charge UID={load}") + headroom = nproc_headroom(proc) return {"launcher": launcher, "manager": owner, "manager_process": manager_process, - "mount": mount, "app": app, "uid": uid, "nproc": limits, - "uid_threads_diagnostic": load, "ancestors": capacity(cgroups, app)} + "mount": mount, "app": app, "uid": uid, **headroom, + "ancestors": capacity(cgroups, app)} def same_lifetime(first, second): @@ -507,12 +606,11 @@ def admit(spec, evidence, pid, proc, cgroups, manager, deadline): observed_manager = proc.identity(evidence["manager_process"]["pid"]) require(observed_manager == evidence["manager_process"], "manager PID/startticks divergent") - load = proc.uid_threads(evidence["uid"]) - require(proc.nproc() == evidence["nproc"] and all( - value == resource.RLIM_INFINITY or value >= load for value in proc.nproc()), - "NPROC/charge UID divergent avant release") + headroom = nproc_headroom(proc) + require(headroom["nproc"] == evidence["nproc"], + "NPROC hérité divergent avant release") return {"properties": observed, "process": identity, "cgroup": group, - "kernel": record, "pidfd": pidfd} + "kernel": record, "pidfd": pidfd, "headroom": headroom} except BaseException: os.close(pidfd) raise @@ -679,7 +777,8 @@ def run_scope(lab, manifest, reserve, journal, proc=None, cgroups=None, # PRECHECK is not journaled until ATTEMPT: failed preflight stays read-only. reserve(evidence) phase("PRECHECK", nproc=evidence["nproc"], - uid_threads=evidence["uid_threads_diagnostic"]) + global_tasks=evidence["global_tasks"], + required_headroom=evidence["required_headroom"]) barrier = barrier_factory(lab / "gate.sock", spec["nonce"], os.getuid(), os.getpid(), started + 5) identity = evidence["launcher"] diff --git a/tooling/probes/step20c-egl-fx/test_cgroup_admission.py b/tooling/probes/step20c-egl-fx/test_cgroup_admission.py index 0b25e72..7157f66 100644 --- a/tooling/probes/step20c-egl-fx/test_cgroup_admission.py +++ b/tooling/probes/step20c-egl-fx/test_cgroup_admission.py @@ -63,6 +63,9 @@ class FakeProc: return dict(self.identities[pid]) def uid_threads(self, uid): + raise AssertionError("diagnostic PID interdit dans admission") + + def global_tasks(self): return self.load def nproc(self): @@ -347,6 +350,159 @@ class UIDThreadTests(unittest.TestCase): self.proc.uid_threads(1000) +class GlobalHeadroomTests(unittest.TestCase): + """Vrai parseur/qualificateur, entrées simulées ; aucun proc hôte lu.""" + + SAMPLE = "0.00 1.23 12.34 2/1415 1234\n" + + def setUp(self): + self.proc = gate.ProcFS() + self.context = patch.object(self.proc, "host_context", return_value=(1, 2)) + self.context.start() + self.addCleanup(self.context.stop) + self.reader = patch.object(self.proc, "bounded_text", return_value=self.SAMPLE) + self.read = self.reader.start() + self.addCleanup(self.reader.stop) + + def test_actual_controller_observed_example_and_resource_injection(self): + with patch.object(resource, "getrlimit", return_value=(60706, 60706)) as limits: + self.assertEqual(gate.nproc_headroom(self.proc)["global_tasks"], 1415) + limits.assert_called_once_with(resource.RLIMIT_NPROC) + + def test_malformed_truncated_extra_or_inconsistent_loadavg_refused(self): + invalid = ("", "0.00 1.23 12.34", self.SAMPLE.rstrip(), + self.SAMPLE + "extra\n", self.SAMPLE.replace("2/1415", "x/1415"), + self.SAMPLE.replace("2/1415", "-1/1415"), + self.SAMPLE.replace("2/1415", "0/0"), + self.SAMPLE.replace("2/1415", "1416/1415"), + self.SAMPLE.replace("2/1415", "2/" + "9" * 30)) + for text in invalid: + with self.subTest(text=text): + self.read.return_value = text + with self.assertRaises(gate.Refusal): + self.proc.global_tasks() + + def test_zero_runnable_is_valid(self): + self.read.return_value = self.SAMPLE.replace("2/1415", "0/1415") + self.assertEqual(self.proc.global_tasks(), 1415) + + def test_missing_unreadable_nonascii_or_changed_context_refuses(self): + for error in (FileNotFoundError("missing"), PermissionError("denied"), + UnicodeError("non ascii")): + with self.subTest(error=error), patch.object( + self.proc, "bounded_text", side_effect=error): + with self.assertRaises(gate.Refusal): + self.proc.global_tasks() + with patch.object(self.proc, "host_context", side_effect=[(1,), (2,)]): + with self.assertRaisesRegex(gate.Refusal, "contexte"): + self.proc.global_tasks() + + def test_finite_minimum_requires_strict_full_reserve(self): + for limits in ((1671, 60706), (60706, 1671), (1415, 1415), (0, 0), + (resource.RLIM_INFINITY, 1671), (1671, resource.RLIM_INFINITY)): + with self.subTest(limits=limits), patch.object( + self.proc, "nproc", return_value=limits): + with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC"): + gate.nproc_headroom(self.proc) + for limits in ((1672, 60706), (resource.RLIM_INFINITY, 1672), + (resource.RLIM_INFINITY, resource.RLIM_INFINITY)): + with self.subTest(limits=limits), patch.object( + self.proc, "nproc", return_value=limits): + self.assertEqual(gate.nproc_headroom(self.proc)["nproc"], limits) + + def test_bounded_fd_reader_rejects_symlink_and_oversize(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + proc = gate.ProcFS(root) + (root / "data").write_text("x" * 257) + with self.assertRaises(gate.Refusal): + proc.bounded_text("data", 256) + (root / "link").symlink_to(root / "data") + with self.assertRaises(OSError): + proc.bounded_text("link", 256) + + def test_fd_reader_rejects_writable_foreign_or_replaced_source(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "data" + path.write_text(self.SAMPLE) + proc = gate.ProcFS(root) + path.chmod(0o666) + with self.assertRaisesRegex(gate.Refusal, "modifiable"): + proc.bounded_text("data", 256) + path.chmod(0o444) + info = path.stat() + foreign = list(info) + foreign[4] = 1001 + with patch.object(gate.os, "fstat", return_value=os.stat_result(foreign)): + with self.assertRaisesRegex(gate.Refusal, "non root"): + proc.bounded_text("data", 256, root_owned=True) + replaced = list(info) + replaced[1] += 1 + with patch.object(Path, "stat", return_value=os.stat_result(replaced)): + with self.assertRaisesRegex(gate.Refusal, "remplacée"): + proc.bounded_text("data", 256) + + def test_invalid_inherited_limits_refuse(self): + for limits in ((-2, 4096), ("4096", 4096), (4096,)): + with self.subTest(limits=limits), patch.object( + self.proc, "nproc", return_value=limits): + with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC invalide"): + gate.nproc_headroom(self.proc) + + def test_actual_host_qualification_refuses_untrusted_contexts(self): + # Stop parser-level mocks: exercise the actual context qualification. + self.context.stop() + device = os.makedev(0, 5) + info = os.stat_result((0o40555, 10, device, 1, 0, 0, 0, 0, 0, 0)) + mount = "1 0 0:5 / /proc rw - proc proc rw\n" + + def text(name, *_args, **_kwargs): + if name == "self/mountinfo": + return mount + if name == "self/status": + return f"Name: fixture\nNSpid: {PID}\n" + return "0 0 4294967295\n" + + def namespace(path): + return ("pid:[4026531836]" if path.name == "pid" + else "user:[4026531837]") + + with (patch.object(Path, "stat", return_value=info), + patch.object(Path, "resolve", lambda path: path), + patch.object(self.proc, "bounded_text", side_effect=text), + patch.object(gate.os, "readlink", side_effect=namespace)): + self.proc.host_context() + for bad in (mount.replace(" - proc proc", " - fuse.lxcfs lxcfs"), + mount + mount, + mount.replace(" / /proc ", " /sub /proc "), + mount + "2 1 0:5 / /proc/loadavg rw - proc proc rw\n"): + with self.subTest(mount=bad): + original, mount = mount, bad + with self.assertRaises(gate.Refusal): + self.proc.host_context() + mount = original + with patch.object(gate.os, "readlink", return_value="pid:[123]"): + with self.assertRaisesRegex(gate.Refusal, "namespace"): + self.proc.host_context() + with patch.object(gate.os, "readlink", side_effect=PermissionError("self")): + with self.assertRaises(PermissionError): + self.proc.host_context() + for nspid in ("", f"NSpid: {PID} 1\n", f"NSpid: {PID + 1}\n", + f"NSpid: {PID}\nNSpid: {PID}\n"): + with self.subTest(nspid=nspid), patch.object( + self.proc, "bounded_text", side_effect=lambda name, *_a: + nspid if name == "self/status" else text(name)): + with self.assertRaisesRegex(gate.Refusal, "vue PID"): + self.proc.host_context() + with patch.object(self.proc, "bounded_text", side_effect=lambda name, *_a: + mount if name == "self/mountinfo" else "0 1000 1\n"): + with self.assertRaisesRegex(gate.Refusal, "UID map"): + self.proc.host_context() + with self.assertRaisesRegex(gate.Refusal, "chemin hôte"): + gate.ProcFS(Path("/tmp/proc")).host_context() + + class AdmissionTests(unittest.TestCase): def setUp(self): self.sentinel = patch.object( @@ -400,6 +556,37 @@ class AdmissionTests(unittest.TestCase): "PAYLOAD_STARTED", "CLEANED"]) self.assertEqual(self.proc.limits, (4096, 4096)) + def test_global_load_fluctuation_does_not_require_pid_census(self): + with patch.object(self.proc, "global_tasks", side_effect=[1415, 1500]): + result, _ = self.execute() + self.assertEqual(result["cause"], "exit") + self.assertEqual(self.barrier.released, 1) + self.assertEqual(self.reservations[0]["global_tasks"], 1415) + + def test_rising_global_load_refuses_release(self): + with patch.object(self.proc, "global_tasks", side_effect=[1415, 3840]): + result, _ = self.execute() + self.assertIn("RLIMIT_NPROC", result["error"]) + self.assertEqual(self.barrier.released, 0) + self.assertEqual(len(self.reservations), 1) + + def test_failed_capacity_preflight_has_no_attempt_or_native_spawn(self): + self.proc.load = 4096 + result, _ = self.execute() + self.assertIn("RLIMIT_NPROC", result["error"]) + self.assertEqual(list(self.lab.iterdir()), []) + self.assertEqual(self.reservations, []) + self.assertEqual(self.journal, []) + self.assertEqual(self.manager.created, 0) + + def test_changed_inherited_nproc_before_release_refuses(self): + evidence = self.preflight() + self.cgroups.present = True + self.proc.limits = (5000, 5000) + with self.assertRaisesRegex(gate.Refusal, "hérité divergent"): + gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager, + time.monotonic() + 1) + def test_session_scope_refused_before_reservation(self): self.proc.identities[PID]["cgroup"] = ( f"/user.slice/user-{UID}.slice/session-6.scope") @@ -545,7 +732,7 @@ class AdmissionTests(unittest.TestCase): def test_failures_in_preflight_reservation_admission_and_drain(self): for error in (OSError("read failure"), KeyboardInterrupt()): - with patch.object(self.proc, "uid_threads", side_effect=error): + with patch.object(self.proc, "global_tasks", side_effect=error): result, _ = self.execute() self.assertNotEqual(result["cause"], "exit") self.assertEqual(self.manager.created, 0)