From d6b8f18b43c9bd9bf7268e48b61392a2d62bb229 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 3 May 2026 22:57:44 +0000 Subject: [PATCH] Pausa: 11 crates del fractal Ente #0 con cerebro completo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PID 1 boot + bus interno autenticado + cerebro KCL/Rust: - 6 lib crates de infra (card, bus, cas, kernel, soma, wasm, snapshot) - ente-brain: motor de reglas O(1), observer Shannon, cristalización, audit hash-chain, persistencia rules.k, Prometheus /metrics - KCL schemas card.k + rule.k como gramática autoritativa - compat-logind D-Bus, ente-echo demo provider, ente-zero PID 1 - 22 tests OK, ~3.8k LOC Rust + ~300 LOC KCL Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + Cargo.lock | 1807 ++++++++++++++++++++ Cargo.toml | 44 + crates/ente-brain/Cargo.toml | 23 + crates/ente-brain/examples/brainctl.rs | 125 ++ crates/ente-brain/schema/rule.k | 159 ++ crates/ente-brain/src/audit.rs | 154 ++ crates/ente-brain/src/crystallize.rs | 169 ++ crates/ente-brain/src/dispatch.rs | 73 + crates/ente-brain/src/engine.rs | 399 +++++ crates/ente-brain/src/introspect.rs | 330 ++++ crates/ente-brain/src/kcl_loader.rs | 135 ++ crates/ente-brain/src/lib.rs | 36 + crates/ente-brain/src/metrics.rs | 119 ++ crates/ente-brain/src/observer.rs | 167 ++ crates/ente-brain/src/rules.rs | 204 +++ crates/ente-bus/Cargo.toml | 22 + crates/ente-bus/examples/busctl.rs | 52 + crates/ente-bus/src/lib.rs | 205 +++ crates/ente-card/Cargo.toml | 11 + crates/ente-card/schema/card.k | 178 ++ crates/ente-card/src/lib.rs | 326 ++++ crates/ente-cas/Cargo.toml | 11 + crates/ente-cas/src/lib.rs | 71 + crates/ente-echo/Cargo.toml | 22 + crates/ente-echo/src/lib.rs | 18 + crates/ente-echo/src/main.rs | 46 + crates/ente-kernel/Cargo.toml | 14 + crates/ente-kernel/src/lib.rs | 11 + crates/ente-kernel/src/sigchld.rs | 66 + crates/ente-kernel/src/surface.rs | 86 + crates/ente-kernel/src/uevent.rs | 146 ++ crates/ente-logind-compat/Cargo.toml | 19 + crates/ente-logind-compat/src/main.rs | 249 +++ crates/ente-snapshot/Cargo.toml | 13 + crates/ente-snapshot/src/lib.rs | 61 + crates/ente-soma/Cargo.toml | 14 + crates/ente-soma/src/lib.rs | 357 ++++ crates/ente-wasm/Cargo.toml | 14 + crates/ente-wasm/src/lib.rs | 118 ++ crates/ente-zero/Cargo.toml | 33 + crates/ente-zero/src/brain_glue.rs | 129 ++ crates/ente-zero/src/bus.rs | 143 ++ crates/ente-zero/src/events.rs | 55 + crates/ente-zero/src/graph/bus_mediator.rs | 179 ++ crates/ente-zero/src/graph/capabilities.rs | 33 + crates/ente-zero/src/graph/devices.rs | 60 + crates/ente-zero/src/graph/lifecycle.rs | 151 ++ crates/ente-zero/src/graph/mod.rs | 158 ++ crates/ente-zero/src/graph/shutdown.rs | 100 ++ crates/ente-zero/src/graph/topology.rs | 35 + crates/ente-zero/src/main.rs | 380 ++++ crates/ente-zero/src/seed.rs | 220 +++ 53 files changed, 7753 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/ente-brain/Cargo.toml create mode 100644 crates/ente-brain/examples/brainctl.rs create mode 100644 crates/ente-brain/schema/rule.k create mode 100644 crates/ente-brain/src/audit.rs create mode 100644 crates/ente-brain/src/crystallize.rs create mode 100644 crates/ente-brain/src/dispatch.rs create mode 100644 crates/ente-brain/src/engine.rs create mode 100644 crates/ente-brain/src/introspect.rs create mode 100644 crates/ente-brain/src/kcl_loader.rs create mode 100644 crates/ente-brain/src/lib.rs create mode 100644 crates/ente-brain/src/metrics.rs create mode 100644 crates/ente-brain/src/observer.rs create mode 100644 crates/ente-brain/src/rules.rs create mode 100644 crates/ente-bus/Cargo.toml create mode 100644 crates/ente-bus/examples/busctl.rs create mode 100644 crates/ente-bus/src/lib.rs create mode 100644 crates/ente-card/Cargo.toml create mode 100644 crates/ente-card/schema/card.k create mode 100644 crates/ente-card/src/lib.rs create mode 100644 crates/ente-cas/Cargo.toml create mode 100644 crates/ente-cas/src/lib.rs create mode 100644 crates/ente-echo/Cargo.toml create mode 100644 crates/ente-echo/src/lib.rs create mode 100644 crates/ente-echo/src/main.rs create mode 100644 crates/ente-kernel/Cargo.toml create mode 100644 crates/ente-kernel/src/lib.rs create mode 100644 crates/ente-kernel/src/sigchld.rs create mode 100644 crates/ente-kernel/src/surface.rs create mode 100644 crates/ente-kernel/src/uevent.rs create mode 100644 crates/ente-logind-compat/Cargo.toml create mode 100644 crates/ente-logind-compat/src/main.rs create mode 100644 crates/ente-snapshot/Cargo.toml create mode 100644 crates/ente-snapshot/src/lib.rs create mode 100644 crates/ente-soma/Cargo.toml create mode 100644 crates/ente-soma/src/lib.rs create mode 100644 crates/ente-wasm/Cargo.toml create mode 100644 crates/ente-wasm/src/lib.rs create mode 100644 crates/ente-zero/Cargo.toml create mode 100644 crates/ente-zero/src/brain_glue.rs create mode 100644 crates/ente-zero/src/bus.rs create mode 100644 crates/ente-zero/src/events.rs create mode 100644 crates/ente-zero/src/graph/bus_mediator.rs create mode 100644 crates/ente-zero/src/graph/capabilities.rs create mode 100644 crates/ente-zero/src/graph/devices.rs create mode 100644 crates/ente-zero/src/graph/lifecycle.rs create mode 100644 crates/ente-zero/src/graph/mod.rs create mode 100644 crates/ente-zero/src/graph/shutdown.rs create mode 100644 crates/ente-zero/src/graph/topology.rs create mode 100644 crates/ente-zero/src/main.rs create mode 100644 crates/ente-zero/src/seed.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58f151a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +Cargo.lock.bak diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4851dde --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1807 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "ente-brain" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64", + "bincode", + "ente-card", + "ente-cas", + "postcard", + "serde", + "serde_json", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "ente-bus" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-card", + "ente-echo", + "postcard", + "serde", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "ente-card" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "ulid", +] + +[[package]] +name = "ente-cas" +version = "0.0.1" +dependencies = [ + "anyhow", + "sha2", + "tracing", +] + +[[package]] +name = "ente-echo" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-bus", + "ente-card", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ente-kernel" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-card", + "libc", + "nix", + "tokio", + "tracing", +] + +[[package]] +name = "ente-logind-compat" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-bus", + "ente-card", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + +[[package]] +name = "ente-snapshot" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-card", + "serde", + "serde_json", + "ulid", +] + +[[package]] +name = "ente-soma" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-bus", + "ente-card", + "libc", + "nix", + "tracing", +] + +[[package]] +name = "ente-wasm" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-card", + "tracing", + "ulid", + "wasmi", + "wat", +] + +[[package]] +name = "ente-zero" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-brain", + "ente-bus", + "ente-card", + "ente-cas", + "ente-echo", + "ente-kernel", + "ente-snapshot", + "ente-soma", + "ente-wasm", + "libc", + "nix", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string-interner" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3275464d7a9f2d4cac57c89c2ef96a8524dba2864c8d6f82e3980baf136f9b" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasmi" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063" +dependencies = [ + "arrayvec", + "multi-stash", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.221.3", +] + +[[package]] +name = "wasmi_collections" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33" +dependencies = [ + "downcast-rs", + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "248.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +dependencies = [ + "wast", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..035d928 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "2" +members = [ + "crates/ente-card", + "crates/ente-bus", + "crates/ente-cas", + "crates/ente-kernel", + "crates/ente-soma", + "crates/ente-wasm", + "crates/ente-snapshot", + "crates/ente-brain", + "crates/ente-zero", + "crates/ente-echo", + "crates/ente-logind-compat", +] + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ulid = { version = "1", features = ["serde"] } +tokio = { version = "1", features = ["rt", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] } +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +libc = "0.2" +anyhow = "1" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +postcard = { version = "1", features = ["use-std"] } +wasmi = "0.40" +wat = "1" +sha2 = "0.10" +bincode = "1" +base64 = "0.22" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true +panic = "abort" diff --git a/crates/ente-brain/Cargo.toml b/crates/ente-brain/Cargo.toml new file mode 100644 index 0000000..0a6958c --- /dev/null +++ b/crates/ente-brain/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ente-brain" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +ente-cas = { path = "../ente-cas" } +serde = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } +tokio = { workspace = true } +bincode = { workspace = true } +base64 = { workspace = true } +postcard = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +[[example]] +name = "brainctl" +path = "examples/brainctl.rs" diff --git a/crates/ente-brain/examples/brainctl.rs b/crates/ente-brain/examples/brainctl.rs new file mode 100644 index 0000000..814c2c7 --- /dev/null +++ b/crates/ente-brain/examples/brainctl.rs @@ -0,0 +1,125 @@ +//! brainctl: cliente CLI del introspect API. +//! +//! Uso: +//! cargo run --example brainctl -p ente-brain -- list-rules +//! cargo run --example brainctl -p ente-brain -- entropy +//! cargo run --example brainctl -p ente-brain -- top 10 +//! cargo run --example brainctl -p ente-brain -- crystals +//! cargo run --example brainctl -p ente-brain -- crystal-kcl 0 +//! +//! Path del socket: $ENTE_BRAIN_SOCK o $XDG_RUNTIME_DIR/ente-brain.sock + +use ente_brain::introspect::{call, IntrospectRequest, IntrospectResponse}; +use std::path::PathBuf; + +fn socket_path() -> PathBuf { + if let Ok(p) = std::env::var("ENTE_BRAIN_SOCK") { + return p.into(); + } + let runtime = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into())); + format!("{runtime}/ente-brain.sock").into() +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("entropy"); + + let req = match cmd { + "list-rules" | "rules" => IntrospectRequest::ListRules, + "entropy" => IntrospectRequest::EntropySnapshot, + "top" => { + let n: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10); + IntrospectRequest::TopCorrelations { n } + } + "crystals" => IntrospectRequest::Crystals, + "crystal-kcl" => { + let i: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + IntrospectRequest::CrystalKcl { index: i } + } + "promote" => { + let i: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + IntrospectRequest::PromoteCrystal { index: i } + } + "remove" => { + let id_s = args.get(2).ok_or_else(|| anyhow::anyhow!("se requiere "))?; + let id: ulid::Ulid = id_s.parse()?; + IntrospectRequest::RemoveRule { id } + } + "audit" => { + let limit: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(20); + IntrospectRequest::ListAudit { limit } + } + other => { + eprintln!("subcomando desconocido: {other}"); + eprintln!("válidos: list-rules | entropy | top | crystals | crystal-kcl | promote | remove | audit "); + std::process::exit(2); + } + }; + + let path = socket_path(); + let resp = call(&path, req).await?; + print_response(&resp); + Ok(()) +} + +fn print_response(r: &IntrospectResponse) { + match r { + IntrospectResponse::Rules(rs) => { + println!("{} reglas vivas:", rs.len()); + for r in rs { + println!(" {} prio={} kind={} actions={} wildcard={}", + r.id, r.priority, r.event_kind_tag, r.action_count, r.scope_wildcard); + } + } + IntrospectResponse::Rule(rule) => match rule { + Some(r) => println!("{r:#?}"), + None => println!("regla no encontrada"), + }, + IntrospectResponse::Entropy { value_bits, sample_size, distinct_kinds, window_full } => { + println!("Shannon entropy : {value_bits:.4} bits"); + println!("Sample size : {sample_size}"); + println!("Distinct kinds : {distinct_kinds}"); + println!("Window full : {window_full}"); + } + IntrospectResponse::Correlations(entries) => { + println!("{} pares (top, ordenado por co-ocurrencia):", entries.len()); + for e in entries { + println!(" n={:>4} P(b|a)={:.3} PMI={:>6.3}b {} → {}", + e.joint_count, e.conditional_prob, e.pmi_bits, e.a, e.b); + } + } + IntrospectResponse::Crystals(cs) => { + println!("{} cristales detectados:", cs.len()); + for (i, c) in cs.iter().enumerate() { + println!(" [{i}] {:?} → {:?} P={:.3} PMI={:.3}b n={}", + c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support); + } + } + IntrospectResponse::Kcl(s) => println!("{s}"), + IntrospectResponse::Promoted { rule_id, kcl_snippet } => { + println!("regla creada: {rule_id}"); + println!("--- KCL para auditoría / persistencia ---"); + println!("{kcl_snippet}"); + } + IntrospectResponse::Removed(was_present) => { + if *was_present { println!("regla eliminada"); } + else { println!("regla no encontrada"); } + } + IntrospectResponse::AuditEntries(entries) => { + println!("{} entries de audit log:", entries.len()); + for e in entries { + let prev = e.prev_sha.map(hex_short).unwrap_or_else(|| "—".into()); + let sha = hex_short(e.sha); + println!(" seq={:>4} t={} prev={} sha={} {:?}", + e.seq, e.timestamp_ms, prev, sha, e.action); + } + } + IntrospectResponse::Error(e) => eprintln!("error: {e}"), + } +} + +fn hex_short(sha: [u8; 32]) -> String { + sha[..4].iter().map(|b| format!("{:02x}", b)).collect::() + ".." +} diff --git a/crates/ente-brain/schema/rule.k b/crates/ente-brain/schema/rule.k new file mode 100644 index 0000000..f7fb468 --- /dev/null +++ b/crates/ente-brain/schema/rule.k @@ -0,0 +1,159 @@ +# ============================================================================ +# rule.k — Triplet [Sujeto + Evento + Acción(Objeto)]. La gramática del +# Cerebro del fractal. Cada regla es una sinapsis: cuando ocurre `when`, +# el motor ejecuta `then` para todos los Entes que cumplen `scope`. +# +# El motor en Rust las indexa por discriminante de EventKind para lookup +# en O(1). Las reglas son inmutables tras carga (Arc). +# ============================================================================ + +schema Rule: + """Una sinapsis del fractal. Determinista, sin estado entre disparos.""" + id: str # Ulid 26 chars + priority: int = 5 # 0..255, mayor = se ejecuta primero + when: EventPattern + then: [Action] + scope: Scope = Scope {} # qué Entes son sujetos válidos + + check: + len(id) == 26, "id debe ser Ulid" + priority >= 0 and priority <= 255, "priority fuera de rango" + len(then) > 0, "regla sin acciones" + + +# ---------- Subject: alcance del sujeto ---------- + +schema Scope: + """Match del sujeto. None en todos los campos = match cualquier Ente.""" + subject_id?: str # Ulid exacto + subject_label?: str # label exacto + subject_has_cap?: Capability # Ente que declara esta capacidad + + check: + subject_id is None or len(subject_id) == 26, "subject_id no es Ulid" + + +# ---------- Event: qué dispara la regla ---------- + +# EventPattern es tagged union recursivo. +# +# Atómicos: +# Single — match un evento por kind +# Sequence — N eventos consecutivos dentro de within_ms +# +# Compuestos (recursivos): +# Either — OR sobre sub-patterns +# All — AND sobre sub-patterns (mismo event/history) +schema EventPattern: + type: "Single" | "Sequence" | "Either" | "All" + kind?: EventKind # Single + kinds?: [EventKind] # Sequence + within_ms?: int = 0 + patterns?: [EventPattern] # Either / All (recursivo) + + check: + type != "Single" or kind is not None, "Single requiere kind" + type != "Sequence" or (kinds is not None and len(kinds) > 0), \ + "Sequence requiere kinds no vacío" + type != "Either" or (patterns is not None and len(patterns) > 0), \ + "Either requiere patterns no vacío" + type != "All" or (patterns is not None and len(patterns) > 0), \ + "All requiere patterns no vacío" + within_ms is None or within_ms >= 0, "within_ms negativo" + + +# EventKind con tag interno + payload opcional según tag. +schema EventKind: + tag: "EnteSpawned" | "EnteDied" | "BusAnnounce" | "BusInvoke" | "BusInvokeOf" | "DeviceAdded" | "DeviceRemoved" | "Custom" + cap?: Capability # para BusInvokeOf + custom?: str # para Custom + + check: + tag != "BusInvokeOf" or cap is not None, "BusInvokeOf requiere cap" + tag != "Custom" or custom is not None, "Custom requiere custom string" + + +# ---------- Action: qué hacer ---------- + +schema Action: + """Una acción ejecutable por el motor. Tagged union con kind.""" + kind: "Log" | "Notify" | "Spawn" | "Invoke" | "Inhibit" + # Log + level?: "trace" | "debug" | "info" | "warn" | "error" + message?: str + # Notify + target_id?: str # Ulid + # Spawn + card_blob?: str # base64-encoded EntityCard JSON + # Invoke + target_cap?: Capability + blob_b64?: str + # Inhibit + reason?: str + + check: + kind != "Log" or message is not None, "Log requiere message" + kind != "Notify" or (target_id is not None and message is not None), \ + "Notify requiere target_id + message" + kind != "Spawn" or card_blob is not None, "Spawn requiere card_blob" + kind != "Invoke" or target_cap is not None, "Invoke requiere target_cap" + kind != "Inhibit" or reason is not None, "Inhibit requiere reason" + + +# ---------- Capability: re-export desde card.k para evitar inclusión circular ---------- + +# En uso real: `import ..ente_card.schema.card` y referencia Capability. +# Aquí declaramos una versión alineada para auto-contención del esquema. +schema Capability: + kind: "FilesystemRoot" | "KernelNetlink" | "Endpoint" | "LegacyLogind" | "Device" | "Spawn" | "Journal" + netlink_family?: "Uevent" | "Route" | "Generic" | "Audit" + endpoint_interface?: str + endpoint_version?: int + device_class?: "Block" | "Tty" | "Input" | "Drm" | "Net" | "Hidraw" + + +# ============================================================================ +# Ejemplo de regla cristalizada (auto-generada por el observador) +# ============================================================================ + +example_rule = Rule { + id = "01KQQ100000000000000000000" + priority = 5 + when = EventPattern { + type = "Single" + kind = EventKind {tag = "EnteSpawned"} + } + scope = Scope { + subject_label = "demo-echo" + } + then = [ + Action { + kind = "Log" + level = "info" + message = "demo-echo encarnado, observando para crystallization" + } + ] +} + +# Ejemplo de regla compuesta: cuando un Ente se anuncia y luego es invocado +# en menos de 500ms, log estructurado para auditoría. +example_sequence = Rule { + id = "01KQQ200000000000000000000" + priority = 7 + when = EventPattern { + type = "Sequence" + kinds = [ + EventKind {tag = "BusAnnounce"} + EventKind {tag = "BusInvoke"} + ] + within_ms = 500 + } + scope = Scope {} + then = [ + Action { + kind = "Log" + level = "info" + message = "patrón Announce→Invoke detectado <500ms" + } + ] +} diff --git a/crates/ente-brain/src/audit.rs b/crates/ente-brain/src/audit.rs new file mode 100644 index 0000000..b38e53d --- /dev/null +++ b/crates/ente-brain/src/audit.rs @@ -0,0 +1,154 @@ +//! Audit log: cada acción mutadora del cerebro deja una entry inmutable +//! con su predecesor encadenado por SHA256 (estilo Merkle). Verificable a +//! posteriori sin confianza en quien escribe. +//! +//! Los entries viven en memoria. Para persistencia, `flush_to_cas()` los +//! escribe al content-addressable store y devuelve el SHA del head, que +//! puede guardarse en un archivo de "head pointer" (fuera de scope aquí). + +use crate::crystallize::Crystal; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use ulid::Ulid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + /// Sequence number monotónico desde el inicio del log. + pub seq: u64, + /// Wall-clock al insertar. + pub timestamp_ms: u64, + /// SHA256 del entry anterior. None para el primer entry. + pub prev_sha: Option<[u8; 32]>, + /// SHA256 de este entry (auto-calculado al construir). + pub sha: [u8; 32], + /// Acción registrada. + pub action: AuditAction, +} + +/// Sin `#[serde(tag)]`: bincode requiere external tagging (default serde +/// para enums) para no usar `deserialize_any`. JSON sigue legible. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AuditAction { + PromoteCrystal { rule_id: Ulid, crystal: Crystal }, + RemoveRule { rule_id: Ulid }, + LoadRulesFile { path: String, count: usize }, +} + +pub struct AuditLog { + entries: VecDeque, + next_seq: u64, + /// Cap del log en memoria. Entries más viejos se descartan tras flush. + cap: usize, +} + +impl AuditLog { + pub fn new() -> Self { + Self::with_cap(512) + } + + pub fn with_cap(cap: usize) -> Self { + Self { entries: VecDeque::new(), next_seq: 0, cap } + } + + /// Apendea una acción. Calcula el SHA encadenado contra el último entry. + pub fn append(&mut self, action: AuditAction) -> AuditEntry { + let prev_sha = self.entries.back().map(|e| e.sha); + let timestamp_ms = now_ms(); + let seq = self.next_seq; + self.next_seq += 1; + + // Pre-construct con sha en cero, luego calcular sha sobre el + // serializado canónico, luego sobreescribir el campo. + let mut entry = AuditEntry { + seq, timestamp_ms, prev_sha, sha: [0u8; 32], action, + }; + entry.sha = compute_sha(&entry); + + if self.entries.len() >= self.cap { + self.entries.pop_front(); + } + self.entries.push_back(entry.clone()); + entry + } + + pub fn recent(&self, limit: usize) -> impl Iterator { + let n = if limit == 0 { self.entries.len() } else { limit.min(self.entries.len()) }; + self.entries.iter().skip(self.entries.len() - n) + } + + pub fn len(&self) -> usize { self.entries.len() } + pub fn is_empty(&self) -> bool { self.entries.is_empty() } + + pub fn head_sha(&self) -> Option<[u8; 32]> { + self.entries.back().map(|e| e.sha) + } + + /// Persiste el entry pasado al CAS y devuelve su SHA. Pensado para + /// snapshots externos — el log en memoria sigue intacto. + pub fn persist_to_cas(entry: &AuditEntry) -> anyhow::Result<[u8; 32]> { + let bytes = serde_json::to_vec(entry)?; + let sha = ente_cas::store(&bytes)?; + Ok(sha) + } +} + +impl Default for AuditLog { + fn default() -> Self { Self::new() } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// SHA256 sobre el entry serializado, EXCLUYENDO el campo sha mismo +/// (que está en cero al momento del cálculo). Determinístico vía postcard +/// para que la verificación sea reproducible. +fn compute_sha(entry: &AuditEntry) -> [u8; 32] { + let bytes = postcard_or_json(entry); + ente_cas::sha256_of(&bytes) +} + +fn postcard_or_json(entry: &AuditEntry) -> Vec { + // Preferimos postcard por estabilidad bit-a-bit. Fallback JSON si falla. + match postcard::to_stdvec(entry) { + Ok(b) => b, + Err(_) => serde_json::to_vec(entry).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chain_links_consecutive_entries() { + let mut log = AuditLog::new(); + let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); + let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); + assert!(e1.prev_sha.is_none()); + assert_eq!(e2.prev_sha, Some(e1.sha)); + assert_ne!(e1.sha, e2.sha); + } + + #[test] + fn seq_monotonic() { + let mut log = AuditLog::new(); + let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); + let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); + assert_eq!(e2.seq, e1.seq + 1); + } + + #[test] + fn cap_evicts_oldest() { + let mut log = AuditLog::with_cap(3); + for _ in 0..5 { + log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); + } + assert_eq!(log.len(), 3); + // El primer seq superviviente debe ser 2. + assert_eq!(log.recent(0).next().unwrap().seq, 2); + } +} diff --git a/crates/ente-brain/src/crystallize.rs b/crates/ente-brain/src/crystallize.rs new file mode 100644 index 0000000..0957e8b --- /dev/null +++ b/crates/ente-brain/src/crystallize.rs @@ -0,0 +1,169 @@ +//! Cristalización: del flujo observado a reglas explícitas. +//! +//! Detecta pares (a, b) donde: +//! - support(a, b) ≥ min_support (suficientes muestras para no ser ruido) +//! - P(b|a) ≥ min_conditional_prob (a predice b con confianza) +//! - PMI(a; b) ≥ min_pmi (más correlacionados que random) +//! +//! Cada cristal puede emitirse como snippet KCL (texto humano-readable) o +//! como `Rule` ejecutable directamente por el motor. + +use crate::observer::Observer; +use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Crystal { + pub antecedent: EventKind, + pub consequent: EventKind, + pub conditional_prob: f64, + pub pmi: f64, + pub support: u64, +} + +#[derive(Debug, Clone, Copy)] +pub struct CrystallizationParams { + pub min_support: u64, + pub min_conditional_prob: f64, + pub min_pmi: f64, +} + +impl Default for CrystallizationParams { + fn default() -> Self { + Self { + min_support: 5, + min_conditional_prob: 0.7, + min_pmi: 0.5, + } + } +} + +pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec { + let mut out = Vec::new(); + for ((a, b), &count) in obs.cooccurrences() { + if count < params.min_support { continue; } + let cp = obs.conditional_prob(a, b); + if cp < params.min_conditional_prob { continue; } + let mi = obs.pmi(a, b); + if mi < params.min_pmi { continue; } + out.push(Crystal { + antecedent: a.clone(), + consequent: b.clone(), + conditional_prob: cp, + pmi: mi, + support: count, + }); + } + // Orden estable: por confianza descendente para fácil inspección. + out.sort_by(|x, y| y.conditional_prob.partial_cmp(&x.conditional_prob).unwrap_or(std::cmp::Ordering::Equal)); + out +} + +/// Genera un snippet KCL representando la regla cristalizada. El snippet usa +/// la sintaxis tagged union del schema `rule.k` (Single + EventKind nested). +pub fn crystal_to_kcl(c: &Crystal) -> String { + let id = Ulid::new(); + format!( +r#"# Auto-cristalizado: +# antecedent → consequent | P(c|a) = {cp:.3}, PMI = {pmi:.3} bits, support = {sup} +Rule {{ + id = "{id}" + priority = 5 + when = EventPattern {{ + type = "Single" + kind = EventKind {{tag = "{ant_tag}"{ant_extra}}} + }} + scope = Scope {{}} + then = [ + Action {{ + kind = "Log" + level = "info" + message = "crystal: {ant_tag} → {con_tag} (auto, P={cp:.2}, PMI={pmi:.2})" + }} + ] +}} +"#, + id = id, + cp = c.conditional_prob, + pmi = c.pmi, + sup = c.support, + ant_tag = kind_tag(&c.antecedent), + ant_extra = kind_extra(&c.antecedent), + con_tag = kind_tag(&c.consequent), + ) +} + +fn kind_tag(k: &EventKind) -> &'static str { + match k { + EventKind::EnteSpawned => "EnteSpawned", + EventKind::EnteDied => "EnteDied", + EventKind::BusAnnounce => "BusAnnounce", + EventKind::BusInvoke => "BusInvoke", + EventKind::BusInvokeOf(_) => "BusInvokeOf", + EventKind::DeviceAdded => "DeviceAdded", + EventKind::DeviceRemoved => "DeviceRemoved", + EventKind::Custom(_) => "Custom", + } +} + +fn kind_extra(k: &EventKind) -> String { + match k { + EventKind::Custom(s) => format!(", custom = \"{}\"", s.replace('"', "\\\"")), + // Para BusInvokeOf el cap se omitiría por simplicidad; el snippet + // promovido es la versión "genérica BusInvoke" salvo que el operador + // edite manualmente. + _ => String::new(), + } +} + +/// Convierte un cristal a una `Rule` ejecutable por el motor. Útil para +/// "auto-aprendizaje" donde cristales se promueven a reglas vivas tras +/// validar con el operador. +pub fn crystal_to_rule(c: &Crystal) -> Rule { + Rule { + id: Ulid::new(), + priority: 5, + when: EventPattern::Single { kind: c.antecedent.clone() }, + scope: Scope::default(), + then: vec![Action::Log { + level: LogLevel::Info, + message: format!( + "crystal: {:?} → {:?} (P={:.2}, PMI={:.2}, n={})", + c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support + ), + }], + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::rules::EventKind::*; + + #[test] + fn detects_perfect_correlation() { + let mut obs = Observer::new(100); + for _ in 0..10 { + obs.record(EnteSpawned); + obs.record(EnteDied); + } + let crystals = detect_crystals(&obs, &CrystallizationParams { + min_support: 3, + min_conditional_prob: 0.5, + min_pmi: 0.0, + }); + assert!(crystals.iter().any(|c| matches!(c.antecedent, EnteSpawned) + && matches!(c.consequent, EnteDied))); + } + + #[test] + fn rejects_below_threshold() { + let mut obs = Observer::new(100); + // Sin co-ocurrencia significativa. + for _ in 0..3 { obs.record(EnteSpawned); } + let crystals = detect_crystals(&obs, &CrystallizationParams::default()); + assert!(crystals.is_empty(), "no debería haber cristales: {:?}", crystals); + } +} diff --git a/crates/ente-brain/src/dispatch.rs b/crates/ente-brain/src/dispatch.rs new file mode 100644 index 0000000..f864300 --- /dev/null +++ b/crates/ente-brain/src/dispatch.rs @@ -0,0 +1,73 @@ +//! Despacho asíncrono de Actions. El motor entrega `Vec>` matched; +//! este módulo las traduce a efectos del fractal vía un `ActionSink` trait. +//! +//! Esto invierte la dependencia: ente-brain no conoce a ente-zero. El init +//! implementa `ActionSink` y wirea spawn/invoke/log a sus propias estructuras. + +use crate::rules::{Action, LogLevel, Rule}; +use std::sync::Arc; +use tracing::{debug, error, info, trace, warn}; + +/// Backend de ejecución de Actions. ente-zero implementa esto delegando a +/// graph_tx (Spawn → SpawnRequest, Invoke → bus call, etc.). +pub trait ActionSink: Send + Sync { + /// Spawn una Card decodificada. Implementación: GraphEvent::SpawnRequest. + fn spawn(&self, card_blob: &str); + /// Invoke por bus. blob crudo; el sink lo enruta vía bus_mediator. + fn invoke(&self, target_cap: ente_card::Capability, blob: Vec); + /// Notifica a un Ente específico (target_id). Implementación: forward por bus. + fn notify(&self, target_id: ulid::Ulid, message: &str); + /// Inhibe un comportamiento (placeholder; semántica depende del sink). + fn inhibit(&self, reason: &str); +} + +/// Sink por defecto que sólo logea. Útil para tests y dev sin runtime. +pub struct NullSink; + +impl ActionSink for NullSink { + fn spawn(&self, card_blob: &str) { + info!(blob_len = card_blob.len(), "NullSink::spawn (no-op)"); + } + fn invoke(&self, target_cap: ente_card::Capability, blob: Vec) { + info!(?target_cap, blob_len = blob.len(), "NullSink::invoke (no-op)"); + } + fn notify(&self, target_id: ulid::Ulid, message: &str) { + info!(%target_id, %message, "NullSink::notify (no-op)"); + } + fn inhibit(&self, reason: &str) { + info!(%reason, "NullSink::inhibit (no-op)"); + } +} + +/// Ejecuta las reglas matched. Cada Rule puede tener N Actions; ejecutamos +/// todas. Las acciones de Log se evalúan inline (tracing es async-safe). +/// Las acciones de Spawn/Invoke/Notify se delegan al sink — el sink decide +/// si procesarlas sincrónica o asincrónicamente. +pub async fn dispatch_actions(rules: &[Arc], sink: &dyn ActionSink) { + for rule in rules { + trace!(id = %rule.id, priority = rule.priority, n = rule.then.len(), "dispatching rule"); + for action in &rule.then { + execute_action(action, sink, rule.id).await; + } + } +} + +async fn execute_action(action: &Action, sink: &dyn ActionSink, rule_id: ulid::Ulid) { + match action { + Action::Log { level, message } => emit_log(level, message, rule_id), + Action::Notify { target_id, message } => sink.notify(*target_id, message), + Action::Spawn { card_blob } => sink.spawn(card_blob), + Action::Invoke { target_cap, blob } => sink.invoke(target_cap.clone(), blob.clone()), + Action::Inhibit { reason } => sink.inhibit(reason), + } +} + +fn emit_log(level: &LogLevel, message: &str, rule_id: ulid::Ulid) { + match level { + LogLevel::Trace => trace!(rule = %rule_id, "{}", message), + LogLevel::Debug => debug!(rule = %rule_id, "{}", message), + LogLevel::Info => info! (rule = %rule_id, "{}", message), + LogLevel::Warn => warn! (rule = %rule_id, "{}", message), + LogLevel::Error => error!(rule = %rule_id, "{}", message), + } +} diff --git a/crates/ente-brain/src/engine.rs b/crates/ente-brain/src/engine.rs new file mode 100644 index 0000000..358ef6c --- /dev/null +++ b/crates/ente-brain/src/engine.rs @@ -0,0 +1,399 @@ +//! Motor de inferencia. HashMap>> para +//! lookup O(1) por tipo de evento, luego filter lineal por scope + filtros +//! del payload (BusInvokeOf, Custom). +//! +//! Inmutabilidad fractal: `Arc` es el unit de compartición. Clonar una +//! regla del motor para entregarla al dispatcher es un refcount bump, no copia. + +use crate::observer::TimedEvent; +use crate::rules::{EventKind, EventPattern, Rule, Scope}; +use ente_card::Capability; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use ulid::Ulid; + +/// Discriminante barato de `EventKind` para indexar el HashMap. Sin payload — +/// el match de payload se hace en una segunda pasada lineal en O(k) donde k +/// es el número de reglas para ese tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EventKindDiscriminant { + EnteSpawned, + EnteDied, + BusAnnounce, + BusInvoke, + BusInvokeOf, + DeviceAdded, + DeviceRemoved, + Custom, +} + +impl From<&EventKind> for EventKindDiscriminant { + fn from(k: &EventKind) -> Self { + match k { + EventKind::EnteSpawned => Self::EnteSpawned, + EventKind::EnteDied => Self::EnteDied, + EventKind::BusAnnounce => Self::BusAnnounce, + EventKind::BusInvoke => Self::BusInvoke, + EventKind::BusInvokeOf(_) => Self::BusInvokeOf, + EventKind::DeviceAdded => Self::DeviceAdded, + EventKind::DeviceRemoved => Self::DeviceRemoved, + EventKind::Custom(_) => Self::Custom, + } + } +} + +/// Snapshot del Ente que disparó el evento. Necesario para evaluar `Scope`. +#[derive(Debug, Clone, Default)] +pub struct SubjectInfo { + pub id: Option, + pub label: Option, + pub capabilities: Vec, +} + +pub struct RuleEngine { + rules: Vec>, + /// Reglas atómicas (Single, Sequence) indexadas por discriminante del + /// kind que las dispara. Lookup O(1). + by_kind: HashMap>>, + /// Reglas compuestas (Either, All): se evalúan contra cada evento. + /// Para fractales con N pequeño no afecta perf; con N grande, optimizar + /// emitiendo a múltiples buckets en insert (fan-out). + compound: Vec>, +} + +impl Default for RuleEngine { + fn default() -> Self { Self::empty() } +} + +impl RuleEngine { + pub fn empty() -> Self { + Self { rules: Vec::new(), by_kind: HashMap::new(), compound: Vec::new() } + } + + /// Carga reglas desde JSON (lista de Rule). Usado tras validación KCL. + pub fn load_json(json: &str) -> anyhow::Result { + let rules: Vec = serde_json::from_str(json)?; + let mut engine = Self::empty(); + for r in rules { + r.validate().map_err(|e| anyhow::anyhow!("regla inválida: {e}"))?; + engine.insert(r); + } + Ok(engine) + } + + pub fn insert(&mut self, rule: Rule) { + let arc = Arc::new(rule); + // Atómicas → bucket por discriminante. Compuestas → bucket fallback. + if let Some(trigger) = arc.when.trigger_kind() { + let disc = EventKindDiscriminant::from(trigger); + self.by_kind.entry(disc).or_default().push(arc.clone()); + } else { + self.compound.push(arc.clone()); + } + self.rules.push(arc); + } + + pub fn remove(&mut self, id: Ulid) -> bool { + let before = self.rules.len(); + self.rules.retain(|r| r.id != id); + for v in self.by_kind.values_mut() { + v.retain(|r| r.id != id); + } + self.compound.retain(|r| r.id != id); + before != self.rules.len() + } + + pub fn rules(&self) -> impl Iterator> { self.rules.iter() } + + pub fn len(&self) -> usize { self.rules.len() } + pub fn is_empty(&self) -> bool { self.rules.is_empty() } + + /// Despacho determinista. Devuelve reglas que matchean, ordenadas por + /// prioridad descendente. Cada Arc se clona (refcount) — sin copiar + /// los datos. + /// + /// `history` es el slice de eventos recientes (en orden cronológico, + /// más reciente al final) usado para evaluar Sequence patterns. + /// Para reglas Single, history se ignora. + /// + /// Si el evento es `BusInvokeOf(_)`, también consultamos el bucket + /// `BusInvoke` (regla genérica que ignora la cap). + pub fn dispatch( + &self, + event: &EventKind, + subject: &SubjectInfo, + history: &[TimedEvent], + ) -> Vec> { + let primary = EventKindDiscriminant::from(event); + let mut buckets: Vec<&Vec>> = Vec::with_capacity(2); + if let Some(v) = self.by_kind.get(&primary) { + buckets.push(v); + } + if matches!(event, EventKind::BusInvokeOf(_)) { + if let Some(v) = self.by_kind.get(&EventKindDiscriminant::BusInvoke) { + buckets.push(v); + } + } + let mut hits: Vec> = buckets.into_iter() + .flat_map(|v| v.iter()) + .filter(|r| matches_pattern(&r.when, event, history)) + .filter(|r| matches_scope(&r.scope, subject)) + .cloned() + .collect(); + // Fallback: reglas compuestas (Either/All) se evalúan siempre. + for r in &self.compound { + if matches_pattern(&r.when, event, history) && matches_scope(&r.scope, subject) { + hits.push(r.clone()); + } + } + hits.sort_by(|a, b| b.priority.cmp(&a.priority)); + hits + } +} + +/// Match recursivo del pattern. Atomic patterns evalúan contra el evento +/// actual + history. Compuestos (Either/All) recursan sobre sus children. +fn matches_pattern(pattern: &EventPattern, event: &EventKind, history: &[TimedEvent]) -> bool { + match pattern { + EventPattern::Single { kind } => matches_event_payload(kind, event), + EventPattern::Sequence { kinds, within_ms } => { + if kinds.is_empty() { return false; } + let last_kind = kinds.last().unwrap(); + if !matches_event_payload(last_kind, event) { return false; } + if history.len() < kinds.len() { return false; } + let tail = &history[history.len() - kinds.len()..]; + for (t, k) in tail.iter().zip(kinds) { + if !matches_event_payload(k, &t.kind) { return false; } + } + if *within_ms > 0 { + let span = tail.last().unwrap().at.duration_since(tail.first().unwrap().at); + if span > Duration::from_millis(*within_ms) { return false; } + } + true + } + EventPattern::Either { patterns } => { + patterns.iter().any(|p| matches_pattern(p, event, history)) + } + EventPattern::All { patterns } => { + patterns.iter().all(|p| matches_pattern(p, event, history)) + } + } +} + +fn matches_event_payload(rule_kind: &EventKind, evt: &EventKind) -> bool { + use EventKind::*; + match (rule_kind, evt) { + (EnteSpawned, EnteSpawned) => true, + (EnteDied, EnteDied) => true, + (BusAnnounce, BusAnnounce) => true, + (BusInvoke, BusInvoke) | (BusInvoke, BusInvokeOf(_)) => true, + (BusInvokeOf(want), BusInvokeOf(got)) => want == got, + (DeviceAdded, DeviceAdded) => true, + (DeviceRemoved, DeviceRemoved) => true, + (Custom(want), Custom(got)) => want == got, + _ => false, + } +} + +fn matches_scope(scope: &Scope, subj: &SubjectInfo) -> bool { + if scope.is_wildcard() { return true; } + if let Some(id) = scope.subject_id { + if subj.id != Some(id) { return false; } + } + if let Some(lbl) = &scope.subject_label { + if subj.label.as_ref() != Some(lbl) { return false; } + } + if let Some(cap) = &scope.subject_has_cap { + if !subj.capabilities.contains(cap) { return false; } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rules::{Action, EventPattern, LogLevel}; + use std::time::{Duration, Instant}; + + fn rule_single(id_str: &str, kind: EventKind, prio: u8) -> Rule { + Rule { + id: id_str.parse().unwrap(), + priority: prio, + when: EventPattern::Single { kind }, + then: vec![Action::Log { + level: LogLevel::Info, + message: id_str.into(), + }], + scope: Scope::default(), + } + } + + fn empty_history() -> Vec { Vec::new() } + + #[test] + fn dispatch_picks_only_matching_kind() { + let mut e = RuleEngine::empty(); + e.insert(rule_single("01KQQ100000000000000000001", EventKind::EnteSpawned, 5)); + e.insert(rule_single("01KQQ100000000000000000002", EventKind::EnteDied, 5)); + let hits = e.dispatch(&EventKind::EnteSpawned, &SubjectInfo::default(), &empty_history()); + assert_eq!(hits.len(), 1); + } + + #[test] + fn priority_orders_descending() { + let mut e = RuleEngine::empty(); + e.insert(rule_single("01KQQ100000000000000000003", EventKind::EnteSpawned, 1)); + e.insert(rule_single("01KQQ100000000000000000004", EventKind::EnteSpawned, 9)); + let hits = e.dispatch(&EventKind::EnteSpawned, &SubjectInfo::default(), &empty_history()); + assert_eq!(hits[0].priority, 9); + assert_eq!(hits[1].priority, 1); + } + + #[test] + fn scope_filters_by_label() { + let mut e = RuleEngine::empty(); + let mut r = rule_single("01KQQ100000000000000000005", EventKind::EnteSpawned, 5); + r.scope = Scope { subject_label: Some("foo".into()), ..Default::default() }; + e.insert(r); + let foo = SubjectInfo { label: Some("foo".into()), ..Default::default() }; + let bar = SubjectInfo { label: Some("bar".into()), ..Default::default() }; + assert_eq!(e.dispatch(&EventKind::EnteSpawned, &foo, &empty_history()).len(), 1); + assert_eq!(e.dispatch(&EventKind::EnteSpawned, &bar, &empty_history()).len(), 0); + } + + #[test] + fn bus_invoke_generic_matches_specific() { + let mut e = RuleEngine::empty(); + e.insert(rule_single("01KQQ100000000000000000006", EventKind::BusInvoke, 5)); + let hits = e.dispatch( + &EventKind::BusInvokeOf(Capability::LegacyLogind), + &SubjectInfo::default(), + &empty_history(), + ); + assert_eq!(hits.len(), 1); + } + + #[test] + fn sequence_pattern_matches_with_history() { + let mut e = RuleEngine::empty(); + let r = Rule { + id: "01KQQ100000000000000000007".parse().unwrap(), + priority: 5, + when: EventPattern::Sequence { + kinds: vec![EventKind::EnteSpawned, EventKind::BusAnnounce], + within_ms: 1000, + }, + then: vec![Action::Log { level: LogLevel::Info, message: "seq".into() }], + scope: Scope::default(), + }; + e.insert(r); + + let now = Instant::now(); + let history = vec![ + TimedEvent { kind: EventKind::EnteSpawned, at: now }, + TimedEvent { kind: EventKind::BusAnnounce, at: now + Duration::from_millis(50) }, + ]; + let hits = e.dispatch(&EventKind::BusAnnounce, &SubjectInfo::default(), &history); + assert_eq!(hits.len(), 1, "esperaba match secuencia, got {}", hits.len()); + } + + #[test] + fn sequence_rejects_outside_time_window() { + let mut e = RuleEngine::empty(); + let r = Rule { + id: "01KQQ100000000000000000008".parse().unwrap(), + priority: 5, + when: EventPattern::Sequence { + kinds: vec![EventKind::EnteSpawned, EventKind::BusAnnounce], + within_ms: 100, + }, + then: vec![Action::Log { level: LogLevel::Info, message: "seq".into() }], + scope: Scope::default(), + }; + e.insert(r); + let now = Instant::now(); + let history = vec![ + TimedEvent { kind: EventKind::EnteSpawned, at: now }, + TimedEvent { kind: EventKind::BusAnnounce, at: now + Duration::from_millis(500) }, + ]; + let hits = e.dispatch(&EventKind::BusAnnounce, &SubjectInfo::default(), &history); + assert!(hits.is_empty(), "no debería matchear fuera de la ventana"); + } + + #[test] + fn either_matches_any_branch() { + let mut e = RuleEngine::empty(); + let r = Rule { + id: "01KQQ100000000000000000010".parse().unwrap(), + priority: 5, + when: EventPattern::Either { patterns: vec![ + EventPattern::Single { kind: EventKind::EnteSpawned }, + EventPattern::Single { kind: EventKind::EnteDied }, + ]}, + then: vec![Action::Log { level: LogLevel::Info, message: "either".into() }], + scope: Scope::default(), + }; + e.insert(r); + assert_eq!(e.dispatch(&EventKind::EnteSpawned, &SubjectInfo::default(), &[]).len(), 1); + assert_eq!(e.dispatch(&EventKind::EnteDied, &SubjectInfo::default(), &[]).len(), 1); + assert_eq!(e.dispatch(&EventKind::BusAnnounce, &SubjectInfo::default(), &[]).len(), 0); + } + + #[test] + fn all_requires_every_branch() { + let mut e = RuleEngine::empty(); + // All: matchear sólo si el evento actual es BusAnnounce Y la + // secuencia EnteSpawned→BusAnnounce ocurrió en history. + let r = Rule { + id: "01KQQ100000000000000000011".parse().unwrap(), + priority: 5, + when: EventPattern::All { patterns: vec![ + EventPattern::Single { kind: EventKind::BusAnnounce }, + EventPattern::Sequence { + kinds: vec![EventKind::EnteSpawned, EventKind::BusAnnounce], + within_ms: 0, + }, + ]}, + then: vec![Action::Log { level: LogLevel::Info, message: "all".into() }], + scope: Scope::default(), + }; + e.insert(r); + + let now = Instant::now(); + let history = vec![ + TimedEvent { kind: EventKind::EnteSpawned, at: now }, + TimedEvent { kind: EventKind::BusAnnounce, at: now + Duration::from_millis(10) }, + ]; + // Single y Sequence ambos matchean → All matches. + assert_eq!(e.dispatch(&EventKind::BusAnnounce, &SubjectInfo::default(), &history).len(), 1); + // Sólo Single matchea (history vacío) → All no matches. + assert!(e.dispatch(&EventKind::BusAnnounce, &SubjectInfo::default(), &[]).is_empty()); + } + + #[test] + fn sequence_requires_correct_order() { + let mut e = RuleEngine::empty(); + let r = Rule { + id: "01KQQ100000000000000000009".parse().unwrap(), + priority: 5, + when: EventPattern::Sequence { + kinds: vec![EventKind::EnteSpawned, EventKind::BusAnnounce], + within_ms: 0, + }, + then: vec![Action::Log { level: LogLevel::Info, message: "seq".into() }], + scope: Scope::default(), + }; + e.insert(r); + let now = Instant::now(); + // Orden invertido en el history. + let history = vec![ + TimedEvent { kind: EventKind::BusAnnounce, at: now }, + TimedEvent { kind: EventKind::EnteSpawned, at: now + Duration::from_millis(10) }, + ]; + // El evento actual es EnteSpawned, pero el último de la secuencia + // requerida es BusAnnounce — no debería matchear. + let hits = e.dispatch(&EventKind::EnteSpawned, &SubjectInfo::default(), &history); + assert!(hits.is_empty()); + } +} diff --git a/crates/ente-brain/src/introspect.rs b/crates/ente-brain/src/introspect.rs new file mode 100644 index 0000000..21a2c32 --- /dev/null +++ b/crates/ente-brain/src/introspect.rs @@ -0,0 +1,330 @@ +//! Introspect API. Unix Domain Socket + framing length-prefijo + bincode. +//! +//! Una herramienta externa (ej. `brainctl`) puede consultar el estado del +//! cerebro sin tocar el bus interno del fractal. Esto separa observación de +//! ejecución — la introspección es read-only por diseño. + +use crate::crystallize::{detect_crystals, Crystal, CrystallizationParams}; +use crate::engine::RuleEngine; +use crate::observer::Observer; +use crate::rules::Rule; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::RwLock; +use tracing::{debug, info, trace, warn}; +use ulid::Ulid; + +const MAX_FRAME: usize = 4 * 1024 * 1024; // 4 MiB — correlation matrices crecen + +/// Estado compartido entre el bucle del Init y el servidor de introspección. +/// `Arc>` permite muchos lectores concurrentes (introspect) y +/// un escritor (el dispatcher de eventos en el bucle primordial). +#[derive(Clone)] +pub struct BrainState { + pub engine: Arc>, + pub observer: Arc>, + pub params: CrystallizationParams, + /// Path opcional donde apendear reglas promovidas como KCL. Si Some, + /// cada PromoteCrystal añade el snippet al archivo (append-only). + pub rules_out: Option>, + /// Audit log en memoria. Cada promote/remove deja huella aquí. + pub audit: Arc>, +} + +impl BrainState { + pub fn new(window_size: usize) -> Self { + Self::with_params(window_size, CrystallizationParams::default()) + } + + pub fn with_params(window_size: usize, params: CrystallizationParams) -> Self { + Self { + engine: Arc::new(RwLock::new(RuleEngine::empty())), + observer: Arc::new(RwLock::new(Observer::new(window_size))), + params, + rules_out: None, + audit: Arc::new(RwLock::new(crate::audit::AuditLog::new())), + } + } + + pub fn with_rules_out(mut self, path: PathBuf) -> Self { + self.rules_out = Some(Arc::new(path)); + self + } +} + +/// Append-only writer del KCL snippet a `rules_out`. Crea el archivo con +/// header si no existe; en caso contrario sólo apendea. +pub fn append_kcl_snippet(path: &Path, snippet: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let exists = path.exists(); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + if !exists { + writeln!(file, "# Reglas promovidas automáticamente desde cristales.")?; + writeln!(file, "# Cada bloque proviene de PromoteCrystal vía brainctl.")?; + writeln!(file)?; + } + writeln!(file, "{snippet}")?; + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum IntrospectRequest { + /// Lista resumida de reglas vivas. + ListRules, + /// Detalle de una regla concreta. + GetRule(Ulid), + /// Snapshot de la entropía y conteos básicos. + EntropySnapshot, + /// Top N pares (a, b) por co-ocurrencia. + TopCorrelations { n: usize }, + /// Cristales detectados con los parámetros del BrainState. + Crystals, + /// Genera el snippet KCL de un cristal específico (índice tras Crystals). + CrystalKcl { index: usize }, + /// Promueve el cristal #index a regla viva en el motor. Devuelve el + /// rule_id asignado y el snippet KCL para auditoría/persistencia. + PromoteCrystal { index: usize }, + /// Elimina una regla viva por id. Útil para revertir un promote. + RemoveRule { id: Ulid }, + /// Lista las últimas N entradas del audit log. limit=0 = todas. + ListAudit { limit: usize }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum IntrospectResponse { + Rules(Vec), + Rule(Option), + Entropy { value_bits: f64, sample_size: u64, distinct_kinds: usize, window_full: bool }, + Correlations(Vec), + Crystals(Vec), + Kcl(String), + /// Resultado de PromoteCrystal: id de la regla creada + snippet KCL para + /// que el operador lo persista en disco si quiere. + Promoted { rule_id: Ulid, kcl_snippet: String }, + /// Resultado de RemoveRule: true si existía, false si ya no. + Removed(bool), + /// Entradas del audit log (más recientes al final). + AuditEntries(Vec), + Error(String), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RuleSummary { + pub id: Ulid, + pub priority: u8, + pub event_kind_tag: String, + pub action_count: usize, + pub scope_wildcard: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CorrelationEntry { + pub a: String, + pub b: String, + pub joint_count: u64, + pub conditional_prob: f64, + pub pmi_bits: f64, +} + +pub struct IntrospectServer { + state: BrainState, +} + +impl IntrospectServer { + pub fn new(state: BrainState) -> Self { Self { state } } + + /// Spawn del listener. Devuelve cuando bind() falla; en caso contrario + /// corre indefinidamente. + pub async fn serve(self, path: &Path) -> anyhow::Result<()> { + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let listener = UnixListener::bind(path)?; + info!(path = %path.display(), "brain introspect escuchando"); + let arc_self = Arc::new(self); + loop { + match listener.accept().await { + Ok((stream, _)) => { + trace!("introspect conn aceptada"); + let me = arc_self.clone(); + tokio::spawn(async move { + if let Err(e) = me.handle(stream).await { + warn!(?e, "introspect conn ended"); + } + }); + } + Err(e) => { + warn!(?e, "introspect accept failed"); + return Ok(()); + } + } + } + } + + async fn handle(self: Arc, mut stream: UnixStream) -> anyhow::Result<()> { + loop { + let mut len_buf = [0u8; 4]; + if stream.read_exact(&mut len_buf).await.is_err() { + return Ok(()); // EOF + } + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME { + anyhow::bail!("frame oversize: {len}"); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + let req: IntrospectRequest = bincode::deserialize(&buf)?; + debug!(?req, "introspect request"); + + let resp = self.dispatch(req).await; + + let out = bincode::serialize(&resp)?; + stream.write_u32(out.len() as u32).await?; + stream.write_all(&out).await?; + } + } + + async fn dispatch(&self, req: IntrospectRequest) -> IntrospectResponse { + match req { + IntrospectRequest::ListRules => { + let engine = self.state.engine.read().await; + let rules = engine.rules() + .map(|r| RuleSummary { + id: r.id, + priority: r.priority, + event_kind_tag: format!("{:?}", r.when), + action_count: r.then.len(), + scope_wildcard: r.scope.is_wildcard(), + }) + .collect(); + IntrospectResponse::Rules(rules) + } + IntrospectRequest::GetRule(id) => { + let engine = self.state.engine.read().await; + let found = engine.rules() + .find(|r| r.id == id) + .map(|r| Rule::clone(r)); + IntrospectResponse::Rule(found) + } + IntrospectRequest::EntropySnapshot => { + let obs = self.state.observer.read().await; + IntrospectResponse::Entropy { + value_bits: obs.shannon_entropy(), + sample_size: obs.total(), + distinct_kinds: obs.marginals().len(), + window_full: obs.current_window() >= obs.window_size(), + } + } + IntrospectRequest::TopCorrelations { n } => { + let obs = self.state.observer.read().await; + let mut entries: Vec = obs.cooccurrences().iter() + .map(|((a, b), &joint)| CorrelationEntry { + a: format!("{:?}", a), + b: format!("{:?}", b), + joint_count: joint, + conditional_prob: obs.conditional_prob(a, b), + pmi_bits: obs.pmi(a, b), + }) + .collect(); + entries.sort_by(|x, y| y.joint_count.cmp(&x.joint_count)); + entries.truncate(n); + IntrospectResponse::Correlations(entries) + } + IntrospectRequest::Crystals => { + let obs = self.state.observer.read().await; + let crystals = detect_crystals(&obs, &self.state.params); + IntrospectResponse::Crystals(crystals) + } + IntrospectRequest::CrystalKcl { index } => { + let obs = self.state.observer.read().await; + let crystals = detect_crystals(&obs, &self.state.params); + match crystals.get(index) { + Some(c) => IntrospectResponse::Kcl(crate::crystallize::crystal_to_kcl(c)), + None => IntrospectResponse::Error(format!("no crystal at index {index}")), + } + } + IntrospectRequest::PromoteCrystal { index } => { + let crystals = { + let obs = self.state.observer.read().await; + detect_crystals(&obs, &self.state.params) + }; + match crystals.get(index) { + Some(c) => { + let rule = crate::crystallize::crystal_to_rule(c); + let snippet = crate::crystallize::crystal_to_kcl(c); + let rule_id = rule.id; + self.state.engine.write().await.insert(rule); + // Persistencia opcional al archivo KCL. + if let Some(path) = self.state.rules_out.as_ref() { + if let Err(e) = append_kcl_snippet(path, &snippet) { + warn!(?e, path = %path.display(), "rules_out append falló"); + } else { + info!(path = %path.display(), %rule_id, "regla persistida a .k"); + } + } + // Audit entry + self.state.audit.write().await.append( + crate::audit::AuditAction::PromoteCrystal { + rule_id, crystal: c.clone(), + } + ); + IntrospectResponse::Promoted { rule_id, kcl_snippet: snippet } + } + None => IntrospectResponse::Error(format!("no crystal at index {index}")), + } + } + IntrospectRequest::RemoveRule { id } => { + let removed = self.state.engine.write().await.remove(id); + if removed { + self.state.audit.write().await.append( + crate::audit::AuditAction::RemoveRule { rule_id: id } + ); + } + IntrospectResponse::Removed(removed) + } + IntrospectRequest::ListAudit { limit } => { + let audit = self.state.audit.read().await; + IntrospectResponse::AuditEntries(audit.recent(limit).cloned().collect()) + } + } + } +} + +// Cliente helper para tools externos (brainctl). +pub async fn call(path: &Path, req: IntrospectRequest) -> anyhow::Result { + let mut stream = UnixStream::connect(path).await?; + let buf = bincode::serialize(&req)?; + stream.write_u32(buf.len() as u32).await?; + stream.write_all(&buf).await?; + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME { + anyhow::bail!("response oversize: {len}"); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + Ok(bincode::deserialize(&buf)?) +} + +/// Consume la lista marginal del observer para humanos. Suprime el detalle +/// crudo de `EventKind` (ej. payloads largos en BusInvokeOf). +pub fn marginal_summary(obs: &Observer) -> Vec<(String, u64)> { + let mut entries: Vec<(String, u64)> = obs.marginals().iter() + .map(|(k, &c)| (format!("{:?}", k), c)) + .collect(); + entries.sort_by(|x, y| y.1.cmp(&x.1)); + entries +} + diff --git a/crates/ente-brain/src/kcl_loader.rs b/crates/ente-brain/src/kcl_loader.rs new file mode 100644 index 0000000..35acac1 --- /dev/null +++ b/crates/ente-brain/src/kcl_loader.rs @@ -0,0 +1,135 @@ +//! Loader de reglas desde archivos `.k` vía subprocess al CLI de KCL. +//! +//! No usamos el SDK Rust de KCL para no arrastrar la dependencia de Go runtime +//! ni cgo. El CLI `kcl` produce JSON validado contra el schema declarado +//! en el propio `.k` — equivalente funcional al SDK con coste cero de compile. +//! +//! Si `kcl` no está en PATH, el caller decide: cargar JSON crudo (skip KCL), +//! o fallar el boot. +//! +//! ## Formato esperado del .k file +//! +//! ```kcl +//! import .rule # schema/rule.k +//! +//! rules: [Rule] = [ +//! Rule { id = "...", priority = 5, when = ..., then = [...] }, +//! ... +//! ] +//! ``` +//! +//! Salida tras `kcl run --format json`: `{"rules": [...]}`. El loader busca +//! la primera array en el JSON (top-level o anidada un nivel) y la deserializa. + +use crate::rules::Rule; +use ente_card::EntityCard; +use std::path::Path; +use std::process::Command; +use tracing::{debug, info}; + +/// Detecta si `kcl` está disponible en PATH. Útil para degradar a JSON-only +/// en entornos sin la toolchain. +pub fn kcl_available() -> bool { + Command::new("kcl") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Ejecuta `kcl run --format=json` y devuelve el JSON crudo. +pub fn run_kcl(path: &Path) -> anyhow::Result { + let output = Command::new("kcl") + .arg("run") + .arg(path) + .arg("--format=json") + .output() + .map_err(|e| anyhow::anyhow!("invocando `kcl`: {e}. ¿Instalado en PATH?"))?; + if !output.status.success() { + anyhow::bail!( + "kcl run {} falló: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + debug!(path = %path.display(), out_bytes = output.stdout.len(), "kcl run ok"); + Ok(String::from_utf8(output.stdout)?) +} + +/// Carga reglas desde un archivo `.k` o JSON. Discrimina por extensión: +/// `.k` → invoca KCL, `.json` → directo. +pub fn load_rules_file(path: &Path) -> anyhow::Result> { + let raw = match path.extension().and_then(|e| e.to_str()) { + Some("k") => { + info!(path = %path.display(), "cargando reglas vía kcl"); + run_kcl(path)? + } + _ => { + info!(path = %path.display(), "cargando reglas como JSON crudo"); + std::fs::read_to_string(path)? + } + }; + extract_rules_from_json(&raw) +} + +/// Extrae un `Vec` de JSON que puede ser: +/// 1. Array directo: `[{...}, {...}]` +/// 2. Object con un campo array: `{"rules": [...]}` +pub fn extract_rules_from_json(raw: &str) -> anyhow::Result> { + let v: serde_json::Value = serde_json::from_str(raw)?; + let arr = match v { + serde_json::Value::Array(_) => v, + serde_json::Value::Object(map) => { + map.into_values() + .find(|x| x.is_array()) + .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))? + } + _ => anyhow::bail!("JSON debe ser array o object con campo array"), + }; + let rules: Vec = serde_json::from_value(arr)?; + Ok(rules) +} + +// ============================================================================ +// Carga de Cards desde KCL/JSON. Cierra la "puerta genética": ninguna Card +// se acepta sin pasar `validate()` extendido en ente-card. +// ============================================================================ + +/// Carga una `EntityCard` desde un archivo `.k` (vía kcl run) o `.json`. +/// Pasa por `EntityCard::validate()` antes de devolver — falla rápida. +pub fn load_card_file(path: &Path) -> anyhow::Result { + let raw = match path.extension().and_then(|e| e.to_str()) { + Some("k") => { + info!(path = %path.display(), "cargando Card vía kcl"); + run_kcl(path)? + } + _ => { + info!(path = %path.display(), "cargando Card como JSON crudo"); + std::fs::read_to_string(path)? + } + }; + let card = extract_card_from_json(&raw)?; + card.validate() + .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?; + Ok(card) +} + +/// Extrae una `EntityCard` de JSON. Acepta: +/// 1. Object directamente serializable como EntityCard +/// 2. Object dict con un único valor que sea EntityCard (KCL output típico) +pub fn extract_card_from_json(raw: &str) -> anyhow::Result { + let v: serde_json::Value = serde_json::from_str(raw)?; + // Intento 1: deserializar el value directamente. + if let Ok(c) = serde_json::from_value::(v.clone()) { + return Ok(c); + } + // Intento 2: si es dict, buscar el primer value que parsee como Card. + if let serde_json::Value::Object(map) = v { + for (_, vv) in map { + if let Ok(c) = serde_json::from_value::(vv) { + return Ok(c); + } + } + } + anyhow::bail!("JSON no contiene una EntityCard válida") +} diff --git a/crates/ente-brain/src/lib.rs b/crates/ente-brain/src/lib.rs new file mode 100644 index 0000000..4aa661e --- /dev/null +++ b/crates/ente-brain/src/lib.rs @@ -0,0 +1,36 @@ +//! ente-brain: motor de reglas determinista + observador estadístico. +//! +//! Tres capas: +//! 1. `rules` — tipos de regla (Triplet: Subject + Event + Action) +//! 2. `engine` — RuleEngine con HashMap>> +//! para dispatch O(1) +//! 3. `dispatch` — ejecutor async de Actions (vía tokio) +//! 4. `observer` — sliding window + marginales + co-ocurrencias +//! + Shannon entropy + información mutua +//! 5. `crystallize` — detección de patrones estadísticamente significativos +//! y generación de snippets KCL +//! 6. `introspect` — Unix socket bincode API para tools externos +//! +//! Diseño de inmutabilidad: +//! - Rules son `Arc` — clonar es zero-copy (refcount bump). +//! - El motor expone sólo lecturas; mutaciones pasan por `insert/remove`. +//! - Observer mantiene contadores incrementales — sin recomputación. + +pub mod audit; +pub mod crystallize; +pub mod dispatch; +pub mod engine; +pub mod introspect; +pub mod kcl_loader; +pub mod metrics; +pub mod observer; +pub mod rules; + +pub use crystallize::{detect_crystals, Crystal, CrystallizationParams}; +pub use dispatch::{dispatch_actions, ActionSink, NullSink}; +pub use engine::{EventKindDiscriminant, RuleEngine, SubjectInfo}; +pub use introspect::{IntrospectRequest, IntrospectResponse, IntrospectServer, BrainState}; +pub use kcl_loader::{kcl_available, load_card_file, load_rules_file}; +pub use metrics::serve_metrics; +pub use observer::{Observer, TimedEvent}; +pub use rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope}; diff --git a/crates/ente-brain/src/metrics.rs b/crates/ente-brain/src/metrics.rs new file mode 100644 index 0000000..8f0e0ba --- /dev/null +++ b/crates/ente-brain/src/metrics.rs @@ -0,0 +1,119 @@ +//! Endpoint Prometheus en TCP. Formato text/plain (exposition format 0.0.4). +//! +//! Sin dependencias adicionales — la cardinalidad de nuestras métricas es +//! pequeña y el formato es trivial. Si crece, sustituir por la crate +//! `prometheus` con su Registry + encoders. + +use crate::introspect::BrainState; +use crate::rules::EventKind; +use std::net::SocketAddr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tracing::{info, trace, warn}; + +/// Lanza el listener Prometheus. Devuelve cuando bind() falla; en caso +/// contrario corre indefinidamente. Pensado para `tokio::spawn`. +pub async fn serve_metrics(state: BrainState, addr: SocketAddr) -> anyhow::Result<()> { + let listener = TcpListener::bind(addr).await?; + info!(?addr, "prometheus /metrics escuchando"); + loop { + match listener.accept().await { + Ok((stream, peer)) => { + trace!(?peer, "metrics scrape"); + let s = state.clone(); + tokio::spawn(async move { + if let Err(e) = handle_scrape(stream, s).await { + warn!(?e, "metrics conn ended"); + } + }); + } + Err(e) => { + warn!(?e, "metrics accept failed"); + return Ok(()); + } + } + } +} + +async fn handle_scrape(mut stream: TcpStream, state: BrainState) -> anyhow::Result<()> { + // Drenamos el request line + headers sin parsear (cualquier path + // responde igual — Prometheus envía GET /metrics típicamente). + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + let body = format_metrics(&state).await; + let resp = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/plain; version=0.0.4\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + body.len(), body + ); + stream.write_all(resp.as_bytes()).await?; + stream.shutdown().await?; + Ok(()) +} + +async fn format_metrics(state: &BrainState) -> String { + let obs = state.observer.read().await; + let engine = state.engine.read().await; + + let mut out = String::with_capacity(2048); + + // ---- Entropía ---- + out.push_str("# HELP ente_brain_entropy_bits Shannon entropy of marginal event distribution.\n"); + out.push_str("# TYPE ente_brain_entropy_bits gauge\n"); + out.push_str(&format!("ente_brain_entropy_bits {:.6}\n", obs.shannon_entropy())); + + // ---- Tamaño de muestra ---- + out.push_str("# HELP ente_brain_events_total Total events recorded by the observer.\n"); + out.push_str("# TYPE ente_brain_events_total counter\n"); + out.push_str(&format!("ente_brain_events_total {}\n", obs.total())); + + // ---- Distinct kinds ---- + out.push_str("# HELP ente_brain_distinct_kinds Number of distinct EventKind tags seen.\n"); + out.push_str("# TYPE ente_brain_distinct_kinds gauge\n"); + out.push_str(&format!("ente_brain_distinct_kinds {}\n", obs.marginals().len())); + + // ---- Window ocupación ---- + out.push_str("# HELP ente_brain_window_size Current sliding window length.\n"); + out.push_str("# TYPE ente_brain_window_size gauge\n"); + out.push_str(&format!("ente_brain_window_size {}\n", obs.current_window())); + + // ---- Reglas vivas ---- + out.push_str("# HELP ente_brain_rules_active Number of rules currently in the engine.\n"); + out.push_str("# TYPE ente_brain_rules_active gauge\n"); + out.push_str(&format!("ente_brain_rules_active {}\n", engine.len())); + + // ---- Eventos por kind ---- + out.push_str("# HELP ente_brain_events_by_kind Events by EventKind tag.\n"); + out.push_str("# TYPE ente_brain_events_by_kind counter\n"); + for (k, c) in obs.marginals() { + out.push_str(&format!( + "ente_brain_events_by_kind{{kind=\"{}\"}} {}\n", + kind_label(k), c + )); + } + + // ---- Cristales detectados (con params actuales) ---- + let crystals = crate::detect_crystals(&obs, &state.params); + out.push_str("# HELP ente_brain_crystals_total Number of crystals detected with current params.\n"); + out.push_str("# TYPE ente_brain_crystals_total gauge\n"); + out.push_str(&format!("ente_brain_crystals_total {}\n", crystals.len())); + + out +} + +fn kind_label(k: &EventKind) -> &'static str { + match k { + EventKind::EnteSpawned => "EnteSpawned", + EventKind::EnteDied => "EnteDied", + EventKind::BusAnnounce => "BusAnnounce", + EventKind::BusInvoke => "BusInvoke", + EventKind::BusInvokeOf(_) => "BusInvokeOf", + EventKind::DeviceAdded => "DeviceAdded", + EventKind::DeviceRemoved => "DeviceRemoved", + EventKind::Custom(_) => "Custom", + } +} diff --git a/crates/ente-brain/src/observer.rs b/crates/ente-brain/src/observer.rs new file mode 100644 index 0000000..df303fb --- /dev/null +++ b/crates/ente-brain/src/observer.rs @@ -0,0 +1,167 @@ +//! Observador estadístico. Mantiene marginales y co-ocurrencias dentro de una +//! ventana deslizante. Calcula entropía de Shannon e información mutua para +//! identificar correlaciones significativas. +//! +//! Diseño: +//! - Counters incrementales: cada `record()` es O(window_size) en el peor +//! caso (actualiza co-ocurrencias con cada evento del window). +//! - Sin recomputaciones globales: marginales y joint counts son state. +//! - El cálculo de H(X), P(B|A), I(A;B) es O(|distinct events|). + +use crate::rules::EventKind; +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +/// Evento timestamped. El timestamp se conserva para futuras políticas de +/// expiración por tiempo (no sólo por count). +#[derive(Debug, Clone)] +pub struct TimedEvent { + pub kind: EventKind, + pub at: Instant, +} + +pub struct Observer { + window: VecDeque, + window_size: usize, + marginal: HashMap, + cooccur: HashMap<(EventKind, EventKind), u64>, + total: u64, +} + +impl Observer { + pub fn new(window_size: usize) -> Self { + Self { + window: VecDeque::with_capacity(window_size), + window_size, + marginal: HashMap::new(), + cooccur: HashMap::new(), + total: 0, + } + } + + /// Registra un evento. Actualiza marginales y co-ocurrencias contra todo + /// evento aún en la ventana. + pub fn record(&mut self, kind: EventKind) { + let now = Instant::now(); + let timed = TimedEvent { kind: kind.clone(), at: now }; + + // Co-ocurrencias: este evento con cada uno previo en ventana. + for w in &self.window { + *self.cooccur + .entry((w.kind.clone(), kind.clone())) + .or_insert(0) += 1; + } + + self.window.push_back(timed); + if self.window.len() > self.window_size { + self.window.pop_front(); + } + + *self.marginal.entry(kind).or_insert(0) += 1; + self.total += 1; + } + + /// Entropía de Shannon de la distribución marginal de eventos. + /// H(X) = −Σ p(x) log₂ p(x). Unidad: bits. + pub fn shannon_entropy(&self) -> f64 { + if self.total == 0 { return 0.0; } + let total = self.total as f64; + self.marginal.values() + .map(|&c| { + let p = c as f64 / total; + if p > 0.0 { -p * p.log2() } else { 0.0 } + }) + .sum() + } + + /// P(b | a) = "dado que algo siguió a `a` dentro del window, qué fracción + /// fue `b`". Suma 1 sobre todos los b posibles para un a fijo. + /// + /// Implementación: cooccur(a, b) / Σ_x cooccur(a, x). Esto da una + /// probabilidad condicional propia [0, 1]. + pub fn conditional_prob(&self, a: &EventKind, b: &EventKind) -> f64 { + let joint = self.cooccur + .get(&(a.clone(), b.clone())) + .copied() + .unwrap_or(0) as f64; + let row_total: u64 = self.cooccur.iter() + .filter_map(|((x, _), c)| if x == a { Some(*c) } else { None }) + .sum(); + if row_total == 0 { 0.0 } else { joint / row_total as f64 } + } + + /// Información mutua puntual entre `a` y `b`: + /// PMI(a, b) = log₂( P(a, b) / (P(a) · P(b)) ). + /// Positivo → más correlacionados de lo que sugiere independencia. + pub fn pmi(&self, a: &EventKind, b: &EventKind) -> f64 { + if self.total == 0 { return 0.0; } + let total = self.total as f64; + let joint = self.cooccur + .get(&(a.clone(), b.clone())) + .copied() + .unwrap_or(0) as f64 / total; + let pa = self.marginal.get(a).copied().unwrap_or(0) as f64 / total; + let pb = self.marginal.get(b).copied().unwrap_or(0) as f64 / total; + if joint == 0.0 || pa == 0.0 || pb == 0.0 { return 0.0; } + (joint / (pa * pb)).log2() + } + + /// Información mutua acumulada de la pareja (a, b) ponderada por su + /// probabilidad conjunta. Útil como medida de "interés" del par. + pub fn weighted_pmi(&self, a: &EventKind, b: &EventKind) -> f64 { + if self.total == 0 { return 0.0; } + let joint = self.cooccur + .get(&(a.clone(), b.clone())) + .copied() + .unwrap_or(0) as f64 / self.total as f64; + joint * self.pmi(a, b) + } + + pub fn marginals(&self) -> &HashMap { &self.marginal } + pub fn cooccurrences(&self) -> &HashMap<(EventKind, EventKind), u64> { &self.cooccur } + pub fn total(&self) -> u64 { self.total } + pub fn window_size(&self) -> usize { self.window_size } + pub fn current_window(&self) -> usize { self.window.len() } + + /// Últimos N eventos del window, en orden cronológico (más viejo primero). + /// Si N > window.len(), devuelve todo el window. + pub fn recent(&self, n: usize) -> impl Iterator { + let start = self.window.len().saturating_sub(n); + self.window.range(start..) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rules::EventKind::*; + + #[test] + fn entropy_zero_for_single_event() { + let mut obs = Observer::new(10); + for _ in 0..5 { obs.record(EnteSpawned); } + // Distribución degenerada: una sola observación posible → H = 0. + assert!(obs.shannon_entropy() < 1e-9); + } + + #[test] + fn entropy_one_for_balanced_binary() { + let mut obs = Observer::new(100); + for _ in 0..10 { obs.record(EnteSpawned); } + for _ in 0..10 { obs.record(EnteDied); } + // Bernoulli(0.5) → H = 1 bit. + assert!((obs.shannon_entropy() - 1.0).abs() < 1e-9); + } + + #[test] + fn conditional_prob_perfect_dependency() { + let mut obs = Observer::new(100); + // Spawned siempre seguido por Died. + for _ in 0..5 { + obs.record(EnteSpawned); + obs.record(EnteDied); + } + let p = obs.conditional_prob(&EnteSpawned, &EnteDied); + assert!(p > 0.0, "esperamos correlación positiva, got {p}"); + } +} diff --git a/crates/ente-brain/src/rules.rs b/crates/ente-brain/src/rules.rs new file mode 100644 index 0000000..e9c7835 --- /dev/null +++ b/crates/ente-brain/src/rules.rs @@ -0,0 +1,204 @@ +//! Tipos de regla. Equivalente Rust de `schema/rule.k`. +//! +//! Cargables desde JSON (que KCL produce tras validación). El motor no acepta +//! Rules construidas a mano sin pasar por validate() — ver `engine::insert`. + +use ente_card::Capability; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +/// Triplet [Sujeto + Evento + Acción]. Inmutable tras carga. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + pub id: Ulid, + #[serde(default = "default_priority")] + pub priority: u8, + pub when: EventPattern, + pub then: Vec, + #[serde(default)] + pub scope: Scope, +} + +fn default_priority() -> u8 { 5 } + +impl Rule { + pub fn validate(&self) -> Result<(), RuleError> { + if self.then.is_empty() { + return Err(RuleError::EmptyActions); + } + self.when.validate_recursive() + } +} + +#[derive(Debug)] +pub enum RuleError { + EmptyActions, + EmptySequence, + EmptyCompound, + InvalidPriority, +} + +impl std::fmt::Display for RuleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyActions => write!(f, "regla sin acciones"), + Self::EmptySequence => write!(f, "Sequence pattern con kinds vacío"), + Self::EmptyCompound => write!(f, "Either/All con patterns vacío"), + Self::InvalidPriority => write!(f, "prioridad fuera de rango"), + } + } +} + +impl std::error::Error for RuleError {} + +/// Match del sujeto. Vacío en todos los campos = match cualquier Ente. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Scope { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_has_cap: Option, +} + +impl Scope { + pub fn is_wildcard(&self) -> bool { + self.subject_id.is_none() + && self.subject_label.is_none() + && self.subject_has_cap.is_none() + } +} + +/// Patrón de evento que dispara una regla. Tagged union — JSON con campo +/// `type`. Soporta composición recursiva (Either/All) sobre Single y +/// Sequence atómicos. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(tag = "type")] +pub enum EventPattern { + /// Match un único evento por kind. + Single { kind: EventKind }, + /// Match si los últimos N eventos del history coinciden en orden con + /// `kinds`, todos dentro de `within_ms` (0 = sin límite temporal). + Sequence { + kinds: Vec, + #[serde(default)] + within_ms: u64, + }, + /// OR: match si cualquier sub-pattern matchea. + Either { patterns: Vec }, + /// AND: match si todos los sub-patterns matchean simultáneamente + /// contra el mismo (event, history). + All { patterns: Vec }, +} + +impl EventPattern { + /// `true` si el pattern es atómico (no recursivo) y puede ser indexado + /// por discriminante de `EventKind` para dispatch O(1). Compuestos + /// (Either/All) se evalúan en un bucket de fallback. + pub fn is_simple(&self) -> bool { + matches!(self, Self::Single { .. } | Self::Sequence { .. }) + } + + /// Última `EventKind` que dispara la evaluación de un pattern atómico. + /// Devuelve None para compuestos. + pub fn trigger_kind(&self) -> Option<&EventKind> { + match self { + Self::Single { kind } => Some(kind), + Self::Sequence { kinds, .. } => kinds.last(), + Self::Either { .. } | Self::All { .. } => None, + } + } + + /// Validación recursiva. Compuestos vacíos se rechazan al cargar. + pub fn validate_recursive(&self) -> Result<(), RuleError> { + match self { + Self::Single { .. } => Ok(()), + Self::Sequence { kinds, .. } => { + if kinds.is_empty() { Err(RuleError::EmptySequence) } else { Ok(()) } + } + Self::Either { patterns } | Self::All { patterns } => { + if patterns.is_empty() { + return Err(RuleError::EmptyCompound); + } + for p in patterns { p.validate_recursive()?; } + Ok(()) + } + } + } +} + +/// Tipo de evento que dispara reglas. Indexado por discriminante en el motor. +/// Diseñado para que `EventKindDiscriminant::from(&kind)` sea barato (no hash +/// del payload, sólo del tag). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum EventKind { + EnteSpawned, + EnteDied, + BusAnnounce, + BusInvoke, + BusInvokeOf(Capability), + DeviceAdded, + DeviceRemoved, + Custom(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { Trace, Debug, Info, Warn, Error } + +impl LogLevel { + pub fn as_str(&self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "PascalCase")] +pub enum Action { + Log { + #[serde(default = "default_log_level")] + level: LogLevel, + message: String, + }, + Notify { + target_id: Ulid, + message: String, + }, + /// `card_blob` es JSON del EntityCard codificado en base64. El motor lo + /// decodifica y forwarda como SpawnRequest al graph. + Spawn { + card_blob: String, + }, + Invoke { + target_cap: Capability, + /// blob crudo (base64 si viene de KCL, bytes ya en Rust). + #[serde(with = "blob_b64")] + blob: Vec, + }, + Inhibit { + reason: String, + }, +} + +fn default_log_level() -> LogLevel { LogLevel::Info } + +mod blob_b64 { + use base64::{engine::general_purpose::STANDARD, Engine}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], s: S) -> Result { + s.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + STANDARD.decode(&s).map_err(serde::de::Error::custom) + } +} diff --git a/crates/ente-bus/Cargo.toml b/crates/ente-bus/Cargo.toml new file mode 100644 index 0000000..fdc1fc7 --- /dev/null +++ b/crates/ente-bus/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ente-bus" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +serde = { workspace = true } +postcard = { workspace = true } +ulid = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +ente-echo = { path = "../ente-echo" } + +[[example]] +name = "busctl" +path = "examples/busctl.rs" diff --git a/crates/ente-bus/examples/busctl.rs b/crates/ente-bus/examples/busctl.rs new file mode 100644 index 0000000..0d5b36e --- /dev/null +++ b/crates/ente-bus/examples/busctl.rs @@ -0,0 +1,52 @@ +//! busctl: cliente CLI para el bus interno del fractal. +//! +//! Uso: +//! cargo run --example busctl -- list-entes +//! cargo run --example busctl -- announce +//! cargo run --example busctl -- power-off +//! +//! Si `ENTE_BUS_SOCK` no está en el entorno, cae al path dev por defecto. + +use ente_bus::{BusClient, BusRequest}; +use std::env; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args: Vec = env::args().collect(); + let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("list-entes"); + + let mut client = match BusClient::from_env().await { + Ok(c) => c, + Err(_) => { + let user = env::var("USER").unwrap_or_else(|_| "ente".into()); + let runtime = env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into())); + let path = format!("{runtime}/ente-bus-{user}.sock"); + eprintln!("ENTE_BUS_SOCK no definido, intentando {path}"); + BusClient::connect(&path).await? + } + }; + + let req = match cmd { + "list-entes" => BusRequest::ListEntes, + "announce" => BusRequest::Announce { capabilities: vec![] }, + "power-off" => BusRequest::PowerOff { interactive: false }, + "reboot" => BusRequest::Reboot { interactive: false }, + "suspend" => BusRequest::Suspend { interactive: false }, + "invoke-echo" => { + let msg = args.get(2).map(|s| s.as_str()).unwrap_or("hola"); + BusRequest::Invoke { + cap: ente_echo::echo_capability(), + blob: msg.as_bytes().to_vec(), + } + } + other => { + eprintln!("subcomando desconocido: {other}"); + eprintln!("válidos: list-entes, announce, power-off, reboot, suspend, invoke-echo "); + std::process::exit(2); + } + }; + let resp = client.call(req).await?; + println!("{resp:#?}"); + Ok(()) +} diff --git a/crates/ente-bus/src/lib.rs b/crates/ente-bus/src/lib.rs new file mode 100644 index 0000000..8d6a71f --- /dev/null +++ b/crates/ente-bus/src/lib.rs @@ -0,0 +1,205 @@ +//! ente-bus: bus de capacidades interno del fractal. +//! +//! Wire format: Unix SOCK_STREAM con framing length-prefijo (u32 BE) + payload +//! postcard. Bidireccional pero por ahora request-response síncrono. +//! +//! Identidad: cada Ente hijo recibe `ENTE_BUS_SOCK` y `ENTE_ID` en su entorno. +//! El cliente lee ambos vía `BusClient::from_env`. + +use ente_card::Capability; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::str::FromStr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; +use ulid::Ulid; + +pub const ENV_BUS_SOCK: &str = "ENTE_BUS_SOCK"; +pub const ENV_ENTE_ID: &str = "ENTE_ID"; +pub const MAX_FRAME: usize = 1 << 20; // 1 MiB — protección contra OOM + +/// Credenciales del peer extraídas vía SO_PEERCRED al accept del bus. +/// Imposibles de falsear desde el cliente — el kernel las inyecta. +/// Definidas aquí (no en ente-zero) porque conceptualmente son atributo +/// del protocolo del bus, no del init. +#[derive(Debug, Clone, Copy)] +pub struct PeerCreds { + pub pid: i32, + pub uid: u32, + pub gid: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BusMessage { + pub from: Option, + pub seq: u64, + pub payload: BusPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BusPayload { + Request(BusRequest), + Response(BusResponse), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BusRequest { + /// Saludo. El Ente anuncia que está vivo y declara sus capacidades. + /// El Init usa esto para saber que el child arrancó correctamente, + /// independientemente de su exit code. + Announce { capabilities: Vec }, + + /// Listar Entes vivos. Útil para debugging y para Entes-supervisor. + ListEntes, + + /// Control de estado del fractal. Traducido desde D-Bus por compat-logind. + PowerOff { interactive: bool }, + Reboot { interactive: bool }, + Suspend { interactive: bool }, + Hibernate { interactive: bool }, + + /// Invocación genérica de capacidad. `cap` debe estar provista por algún + /// Ente del grafo; el blob es el argumento opaco que el proveedor parsea. + Invoke { cap: Capability, blob: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BusResponse { + Ok, + Error(String), + Entes(Vec), + Invoked { result: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnteInfo { + pub id: Ulid, + pub label: String, + pub provides: Vec, + pub pid: Option, +} + +pub async fn write_frame(w: &mut W, msg: &BusMessage) -> anyhow::Result<()> { + let bytes = postcard::to_stdvec(msg)?; + if bytes.len() > MAX_FRAME { + anyhow::bail!("frame too large: {} > {}", bytes.len(), MAX_FRAME); + } + w.write_u32(bytes.len() as u32).await?; + w.write_all(&bytes).await?; + Ok(()) +} + +pub async fn read_frame(r: &mut R) -> anyhow::Result { + let len = r.read_u32().await? as usize; + if len > MAX_FRAME { + anyhow::bail!("frame oversize: {len}"); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf).await?; + Ok(postcard::from_bytes(&buf)?) +} + +pub struct BusClient { + stream: UnixStream, + seq: u64, + self_id: Option, +} + +/// Trait que un Ente proveedor implementa para servir invokes que el bus le +/// forwarda. Sync por simplicidad — un handler async se cubre con +/// `tokio::task::block_in_place` o un canal hacia un task externo. +pub trait InvokeHandler { + fn handle(&mut self, cap: ente_card::Capability, blob: Vec) -> BusResponse; +} + +/// Conexión long-lived para Entes que proveen capacidades. A diferencia de +/// `BusClient` (request-response y desconecta), `BusServer`: +/// 1. Anuncia su identidad al bus +/// 2. Mantiene la conexión abierta +/// 3. Atiende invokes forwardeados por el bus en bucle +pub struct BusServer { + stream: UnixStream, + self_id: Ulid, +} + +impl BusServer { + pub async fn from_env() -> anyhow::Result { + let path = std::env::var(ENV_BUS_SOCK) + .map_err(|_| anyhow::anyhow!("{} no definido", ENV_BUS_SOCK))?; + let id_s = std::env::var(ENV_ENTE_ID) + .map_err(|_| anyhow::anyhow!("{} no definido", ENV_ENTE_ID))?; + let self_id = Ulid::from_str(&id_s) + .map_err(|_| anyhow::anyhow!("{} no es un Ulid válido: {id_s}", ENV_ENTE_ID))?; + let stream = UnixStream::connect(&path).await?; + Ok(Self { stream, self_id }) + } + + pub async fn announce(&mut self, capabilities: Vec) -> anyhow::Result<()> { + let req = BusMessage { + from: Some(self.self_id), + seq: 1, + payload: BusPayload::Request(BusRequest::Announce { capabilities }), + }; + write_frame(&mut self.stream, &req).await?; + let resp = read_frame(&mut self.stream).await?; + match resp.payload { + BusPayload::Response(BusResponse::Ok) => Ok(()), + BusPayload::Response(other) => anyhow::bail!("Announce rechazado: {other:?}"), + BusPayload::Request(_) => anyhow::bail!("expected Response, got Request"), + } + } + + /// Bucle principal del proveedor. Atiende invokes hasta que la conexión + /// se cierra (el bus muere o el Ente recibe SIGTERM y nosotros dropeamos). + pub async fn serve(mut self, mut handler: H) -> anyhow::Result<()> { + loop { + let msg = read_frame(&mut self.stream).await?; + let resp = match msg.payload { + BusPayload::Request(BusRequest::Invoke { cap, blob }) => { + handler.handle(cap, blob) + } + BusPayload::Request(other) => { + BusResponse::Error(format!("BusServer no maneja {other:?}")) + } + BusPayload::Response(_) => continue, + }; + let out = BusMessage { + from: Some(self.self_id), + seq: msg.seq, + payload: BusPayload::Response(resp), + }; + write_frame(&mut self.stream, &out).await?; + } + } +} + +impl BusClient { + pub async fn connect(path: impl AsRef) -> anyhow::Result { + let stream = UnixStream::connect(path).await?; + let self_id = std::env::var(ENV_ENTE_ID) + .ok() + .and_then(|s| Ulid::from_str(&s).ok()); + Ok(Self { stream, seq: 0, self_id }) + } + + pub async fn from_env() -> anyhow::Result { + let path = std::env::var(ENV_BUS_SOCK) + .map_err(|_| anyhow::anyhow!("{} no definido", ENV_BUS_SOCK))?; + Self::connect(&path).await + } + + pub async fn call(&mut self, req: BusRequest) -> anyhow::Result { + self.seq = self.seq.wrapping_add(1); + let msg = BusMessage { + from: self.self_id, + seq: self.seq, + payload: BusPayload::Request(req), + }; + write_frame(&mut self.stream, &msg).await?; + let resp = read_frame(&mut self.stream).await?; + match resp.payload { + BusPayload::Response(r) => Ok(r), + BusPayload::Request(_) => anyhow::bail!("expected response, got request"), + } + } +} diff --git a/crates/ente-card/Cargo.toml b/crates/ente-card/Cargo.toml new file mode 100644 index 0000000..f6352b3 --- /dev/null +++ b/crates/ente-card/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ente-card" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } diff --git a/crates/ente-card/schema/card.k b/crates/ente-card/schema/card.k new file mode 100644 index 0000000..bc92b35 --- /dev/null +++ b/crates/ente-card/schema/card.k @@ -0,0 +1,178 @@ +# ============================================================================ +# card.k — Genética del Ente. Esquema KCL para EntityCard. +# +# Esta es la gramática autoritativa: cualquier Card que se cargue al fractal +# debe pasar la validación de este esquema. El boot de PID 1 acepta JSON que +# cumple este shape (KCL exporta JSON tras validar `check:`). +# +# Para validar manualmente: +# kcl run examples/my-card.k --schema schema/card.k +# +# Cada `check:` es invariante de fractal. Romperlo = Card inválida = no boot. +# ============================================================================ + +# ---------- Identidad ---------- + +schema EntityCard: + """Tarjeta de Identidad. Inmutable: cambios = nueva Card con nuevo id.""" + schema_version: int = 1 + id: str # Ulid (26 chars, Crockford base32) + lineage?: str # parent Ulid; None = Ente raíz + label: str # legible, no es identificador + provides: [Capability] = [] # contrato hacia el grafo + requires: [Capability] = [] # contrato del grafo hacia el Ente + soma: SomaSpec # cuerpo: aislamiento + recursos + payload: Payload # cómo encarnar (Wasm/Native/Virtual) + supervision: Supervision # política tras muerte + genesis?: [EntityCard] = [] # hijos a instanciar al encarnar + + check: + schema_version == 1, "schema version no soportada" + len(label) > 0, "label vacío" + len(id) == 26, "id debe ser Ulid (26 caracteres)" + # Auto-dependencia: una capacidad no puede estar en requires y provides + all c in requires { c not in provides }, "self-dependency: ${c}" + + +# ---------- Capacidades (typed enum) ---------- + +# KCL no tiene sum types nativos; usamos tagged union: `kind` + campos opcionales +# que sólo aplican según el kind. Las invariantes en `check:` aseguran consistencia. +schema Capability: + """Capacidad tipada del fractal. NUNCA usar strings libres.""" + kind: "FilesystemRoot" | "KernelNetlink" | "Endpoint" | "LegacyLogind" | "Device" | "Spawn" | "Journal" + netlink_family?: "Uevent" | "Route" | "Generic" | "Audit" + endpoint_interface?: str # 32-char hex (UUID 16 bytes) + endpoint_version?: int + device_class?: "Block" | "Tty" | "Input" | "Drm" | "Net" | "Hidraw" + + check: + kind != "KernelNetlink" or netlink_family is not None, \ + "KernelNetlink requiere netlink_family" + kind != "Endpoint" or (endpoint_interface is not None and endpoint_version is not None), \ + "Endpoint requiere interface + version" + kind != "Endpoint" or len(endpoint_interface) == 32, \ + "endpoint_interface debe ser hex de 32 chars" + kind != "Device" or device_class is not None, \ + "Device requiere device_class" + + +# ---------- Soma: cuerpo + restricciones de recursos ---------- + +schema SomaSpec: + """Aislamiento + recursos. Validados por KCL antes de tocar el kernel.""" + namespaces: NamespaceSet = NamespaceSet {} + rlimits: ResourceLimits = ResourceLimits {} + cgroup: CgroupSpec = CgroupSpec {} + cpu_affinity?: [int] # CPU pinning + + check: + cpu_affinity is None or all c in cpu_affinity { c >= 0 and c < 1024 }, \ + "cpu_affinity fuera de rango [0, 1024)" + + +schema NamespaceSet: + mount: bool = False + pid: bool = False + net: bool = False + uts: bool = False + ipc: bool = False + user: bool = False + cgroup: bool = False + + +schema ResourceLimits: + """Restricciones nativas validadas en KCL — el kernel sólo ve valores sanos.""" + mem_bytes?: int # RLIMIT_AS + nproc?: int # RLIMIT_NPROC + nofile?: int # RLIMIT_NOFILE + energy_budget_mw?: int # presupuesto energético (futuro) + + check: + mem_bytes is None or mem_bytes > 0, "mem_bytes debe ser positivo" + mem_bytes is None or mem_bytes <= 1099511627776, "mem_bytes > 1 TiB sospechoso" + nproc is None or (nproc > 0 and nproc <= 65535), "nproc fuera de rango" + nofile is None or (nofile > 0 and nofile <= 1048576), "nofile fuera de rango" + energy_budget_mw is None or energy_budget_mw > 0, "energy_budget_mw debe ser positivo" + + +schema CgroupSpec: + """Cgroup v2: path + weights. cpu_weight 1..10000 según kernel docs.""" + path: str = "" + cpu_weight?: int + io_weight?: int + + check: + cpu_weight is None or (cpu_weight >= 1 and cpu_weight <= 10000), \ + "cpu_weight 1..10000" + io_weight is None or (io_weight >= 1 and io_weight <= 10000), \ + "io_weight 1..10000" + + +# ---------- Payload: tagged union de cómo encarnar ---------- + +schema Payload: + """Una variante por Card. Set exactly one of: Wasm, Native, Virtual, Legacy.""" + kind: "Wasm" | "Native" | "Virtual" | "Legacy" + # Wasm + module_sha256?: str # hex 64 chars + entry?: str + # Native / Legacy + exec?: str + argv?: [str] = [] + envp?: [{str: str}] = [] + # Legacy + fakes?: ["SystemdLogind" | "SystemdHostnamed" | "SystemdNotify"] = [] + + check: + kind != "Wasm" or (module_sha256 is not None and entry is not None), \ + "Wasm requiere module_sha256 + entry" + kind != "Wasm" or len(module_sha256) == 64, "module_sha256 debe ser hex de 64 chars" + kind != "Native" or exec is not None, "Native requiere exec" + kind != "Legacy" or exec is not None, "Legacy requiere exec" + + +# ---------- Supervision ---------- + +schema Supervision: + kind: "Restart" | "OneShot" | "Delegate" + initial_ms?: int # ms — backoff inicial para Restart + max_ms?: int # ms — backoff máximo + + check: + kind != "Restart" or (initial_ms is not None and max_ms is not None), \ + "Restart requiere initial_ms + max_ms" + initial_ms is None or initial_ms >= 0, "initial_ms negativo" + max_ms is None or max_ms >= initial_ms or max_ms is None, \ + "max_ms < initial_ms es contradictorio" + + +# ============================================================================ +# Herencia: EnteWeb hereda de EnteBase con campos pre-rellenados. +# ============================================================================ + +schema EnteBase(EntityCard): + """Base para Entes managed: declara Spawn provider y Journal por defecto.""" + schema_version = 1 + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + soma = SomaSpec { + rlimits = ResourceLimits {nofile = 4096} + cgroup = CgroupSpec {path = "ente.slice/managed", cpu_weight = 100} + } + + +schema EnteWeb(EnteBase): + """Hereda EnteBase, declara endpoint + cap LegacyLogind como ejemplo.""" + provides = [ + Capability {kind = "Journal"} + Capability { + kind = "Endpoint" + endpoint_interface = "deadbeefcafe1234deadbeefcafe1234" + endpoint_version = 1 + } + ] + soma = SomaSpec { + namespaces = NamespaceSet {net = True, mount = True, pid = True} + rlimits = ResourceLimits {nofile = 16384, mem_bytes = 536870912} # 512 MiB + cgroup = CgroupSpec {path = "ente.slice/web", cpu_weight = 200, io_weight = 100} + } diff --git a/crates/ente-card/src/lib.rs b/crates/ente-card/src/lib.rs new file mode 100644 index 0000000..4449071 --- /dev/null +++ b/crates/ente-card/src/lib.rs @@ -0,0 +1,326 @@ +//! ente-card: definición de la Tarjeta de Identidad del Ente. +//! +//! Una `EntityCard` no describe un proceso — describe una identidad en el +//! grafo del fractal. El Init la lee y decide cómo *encarnarla*: como ELF +//! nativo, módulo Wasm, wrapper legacy, o nodo virtual sin proceso. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fmt; +use std::time::Duration; +use ulid::Ulid; + +/// Versión del esquema de la Card. Cambiar = romper compatibilidad del fractal. +pub const CARD_SCHEMA_VERSION: u16 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityCard { + pub schema_version: u16, + pub id: Ulid, + pub lineage: Option, + pub label: String, + pub provides: BTreeSet, + pub requires: BTreeSet, + pub soma: SomaSpec, + pub payload: Payload, + pub supervision: Supervision, + /// Hijos a instanciar inmediatamente cuando esta Card se encarna. Se + /// consumen una vez (no se replican en restarts del padre — el grafo + /// re-emerge desde la Semilla viva, no desde la persistencia de la Card). + #[serde(default)] + pub genesis: Vec, +} + +impl EntityCard { + pub fn validate(&self) -> Result<(), CardError> { + if self.schema_version != CARD_SCHEMA_VERSION { + return Err(CardError::SchemaMismatch { + got: self.schema_version, + expected: CARD_SCHEMA_VERSION, + }); + } + if self.label.is_empty() { + return Err(CardError::EmptyLabel); + } + if self.label.len() > 256 { + return Err(CardError::LabelTooLong(self.label.len())); + } + // Una capacidad simultáneamente en `requires` y `provides` indica un + // ciclo de auto-dependencia que el grafo no puede resolver. + for cap in &self.requires { + if self.provides.contains(cap) { + return Err(CardError::SelfDependency(cap.clone())); + } + } + // Coherencia del payload con sus invariantes. + validate_payload(&self.payload)?; + // ResourceLimits: rangos sanos. + validate_rlimits(&self.soma.rlimits)?; + // Cgroup weights: 1..10000 según docs del kernel cgroup v2. + validate_cgroup(&self.soma.cgroup)?; + // Validación recursiva de genesis. Si una hija es inválida, la + // Semilla entera se rechaza — falla rápida en boot. + for child in &self.genesis { + child.validate()?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub enum CardError { + SchemaMismatch { got: u16, expected: u16 }, + EmptyLabel, + LabelTooLong(usize), + SelfDependency(Capability), + EmptyExec, + SentinelWasmHash, + InvalidRlimit(&'static str), + InvalidCgroupWeight(&'static str), +} + +impl fmt::Display for CardError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SchemaMismatch { got, expected } => { + write!(f, "schema version mismatch: got {got}, expected {expected}") + } + Self::EmptyLabel => write!(f, "card label is empty"), + Self::LabelTooLong(n) => write!(f, "label demasiado largo ({n} bytes, max 256)"), + Self::SelfDependency(c) => write!(f, "card both requires and provides {c:?}"), + Self::EmptyExec => write!(f, "Native/Legacy payload con exec vacío"), + Self::SentinelWasmHash => write!(f, "Wasm payload con sha256 sentinel (todo ceros)"), + Self::InvalidRlimit(s) => write!(f, "rlimit inválido: {s}"), + Self::InvalidCgroupWeight(s) => write!(f, "cgroup weight fuera de rango [1,10000]: {s}"), + } + } +} + +impl std::error::Error for CardError {} + +fn validate_payload(p: &Payload) -> Result<(), CardError> { + match p { + Payload::Native { exec, .. } | Payload::Legacy { exec, .. } => { + if exec.trim().is_empty() { + return Err(CardError::EmptyExec); + } + } + Payload::Wasm { module_sha256, .. } => { + // Sentinel [0u8; 32] indica "no resoluble" — usado en dev como + // fallback. En prod debe ser un hash real. + if module_sha256.iter().all(|&b| b == 0) { + return Err(CardError::SentinelWasmHash); + } + } + Payload::Virtual => {} + } + Ok(()) +} + +fn validate_rlimits(rl: &ResourceLimits) -> Result<(), CardError> { + if let Some(m) = rl.mem_bytes { + if m == 0 { return Err(CardError::InvalidRlimit("mem_bytes=0")); } + if m > 1u64 << 40 { return Err(CardError::InvalidRlimit("mem_bytes>1TiB")); } + } + if let Some(n) = rl.nproc { + if n == 0 || n > 65535 { return Err(CardError::InvalidRlimit("nproc fuera de [1,65535]")); } + } + if let Some(n) = rl.nofile { + if n == 0 || n > 1_048_576 { return Err(CardError::InvalidRlimit("nofile fuera de [1,1M]")); } + } + Ok(()) +} + +fn validate_cgroup(cg: &CgroupSpec) -> Result<(), CardError> { + if let Some(w) = cg.cpu_weight { + if !(1..=10000).contains(&w) { return Err(CardError::InvalidCgroupWeight("cpu_weight")); } + } + if let Some(w) = cg.io_weight { + if !(1..=10000).contains(&w) { return Err(CardError::InvalidCgroupWeight("io_weight")); } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Capability { + /// Provee un punto de montaje root para Entes hijos. + FilesystemRoot, + /// Acceso a una familia de netlink. + KernelNetlink(NetlinkFamily), + /// Endpoint del bus interno del fractal — equivalente tipado de un nombre + /// D-Bus, sin la string libre. + Endpoint { interface: InterfaceId, version: u16 }, + /// Reemplazo del shim de systemd-logind. Solo Ente #compat-logind lo provee. + LegacyLogind, + /// Acceso crudo a una clase de dispositivo. Capacidad escalada. + Device { class: DeviceClass }, + /// Permiso de instanciar Entes hijos. Por defecto solo PID 1 lo tiene. + Spawn, + /// Acceso a logging estructurado del fractal. + Journal, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum NetlinkFamily { Uevent, Route, Generic, Audit } + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum DeviceClass { Block, Tty, Input, Drm, Net, Hidraw } + +/// Identificador de interfaz del bus interno. UUID, no string. Para extender +/// el protocolo del fractal, generas un UUID nuevo y versionas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct InterfaceId(pub [u8; 16]); + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SomaSpec { + pub namespaces: NamespaceSet, + pub rlimits: ResourceLimits, + pub cgroup: CgroupSpec, + pub cpu_affinity: Option>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NamespaceSet { + pub mount: bool, + pub pid: bool, + pub net: bool, + pub uts: bool, + pub ipc: bool, + pub user: bool, + pub cgroup: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResourceLimits { + pub mem_bytes: Option, + pub nproc: Option, + pub nofile: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CgroupSpec { + pub path: String, + pub cpu_weight: Option, + pub io_weight: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Payload { + Wasm { module_sha256: [u8; 32], entry: String }, + Native { + exec: String, + argv: Vec, + envp: Vec<(String, String)>, + }, + /// Sin proceso. Nodo lógico del grafo (agregadores, mediators). + Virtual, + /// Wrapper de daemon legacy. `fakes` activa shims D-Bus / sd_notify. + Legacy { + exec: String, + argv: Vec, + fakes: BTreeSet, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum LegacyFacade { + SystemdLogind, + SystemdHostnamed, + SystemdNotify, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Supervision { + Restart { + #[serde(with = "duration_millis")] + initial: Duration, + #[serde(with = "duration_millis")] + max: Duration, + }, + OneShot, + Delegate, +} + +mod duration_millis { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(d: &Duration, s: S) -> Result { + s.serialize_u64(d.as_millis() as u64) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let ms = u64::deserialize(d)?; + Ok(Duration::from_millis(ms)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seed_card_validates() { + let card = EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: "ente-zero".into(), + provides: [Capability::Spawn, Capability::Journal].into_iter().collect(), + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis: vec![], + }; + card.validate().unwrap(); + } + + #[test] + fn self_dependency_rejected() { + let mut s = BTreeSet::new(); + s.insert(Capability::Journal); + let card = EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: "bad".into(), + provides: s.clone(), + requires: s, + soma: SomaSpec::default(), + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis: vec![], + }; + assert!(matches!(card.validate(), Err(CardError::SelfDependency(_)))); + } + + #[test] + fn invalid_genesis_propagates() { + let bad_child = EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: "".into(), + provides: BTreeSet::new(), + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis: vec![], + }; + let parent = EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: "parent".into(), + provides: BTreeSet::new(), + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis: vec![bad_child], + }; + assert!(matches!(parent.validate(), Err(CardError::EmptyLabel))); + } +} diff --git a/crates/ente-cas/Cargo.toml b/crates/ente-cas/Cargo.toml new file mode 100644 index 0000000..df0c34c --- /dev/null +++ b/crates/ente-cas/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ente-cas" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +sha2 = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/crates/ente-cas/src/lib.rs b/crates/ente-cas/src/lib.rs new file mode 100644 index 0000000..7e99700 --- /dev/null +++ b/crates/ente-cas/src/lib.rs @@ -0,0 +1,71 @@ +//! Content-addressable store. Resuelve `Payload::Wasm.module_sha256` (y en +//! el futuro otros payloads firmados) desde el sistema de archivos con +//! verificación de hash. Path por defecto: `$XDG_DATA_HOME/ente/cas/`. +//! +//! Override por env: `ENTE_CAS_ROOT`. + +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use tracing::debug; + +pub fn cas_root() -> PathBuf { + if let Ok(p) = std::env::var("ENTE_CAS_ROOT") { + return p.into(); + } + let base = if let Ok(d) = std::env::var("XDG_DATA_HOME") { + d + } else if let Ok(h) = std::env::var("HOME") { + format!("{h}/.local/share") + } else { + "/var/lib".into() + }; + PathBuf::from(base).join("ente").join("cas") +} + +pub fn sha256_of(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +pub fn hex(sha: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in sha { + s.push_str(&format!("{:02x}", b)); + } + s +} + +pub fn resolve(sha: &[u8; 32]) -> anyhow::Result> { + let path = cas_root().join(hex(sha)); + let bytes = std::fs::read(&path) + .map_err(|e| anyhow::anyhow!("CAS read {}: {e}", path.display()))?; + let actual = sha256_of(&bytes); + if &actual != sha { + anyhow::bail!( + "CAS hash mismatch en {}: declarado={} real={}", + path.display(), hex(sha), hex(&actual) + ); + } + Ok(bytes) +} + +/// Almacena bytes en el CAS, devuelve su SHA. Idempotente: si el archivo ya +/// existe con el mismo hash, no reescribe. +pub fn store(bytes: &[u8]) -> anyhow::Result<[u8; 32]> { + let sha = sha256_of(bytes); + let root = cas_root(); + std::fs::create_dir_all(&root) + .map_err(|e| anyhow::anyhow!("CAS mkdir {}: {e}", root.display()))?; + let path = root.join(hex(&sha)); + if !path.exists() { + // Escritura atómica: crear .tmp y rename. + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, bytes) + .map_err(|e| anyhow::anyhow!("CAS write {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, &path) + .map_err(|e| anyhow::anyhow!("CAS rename {}: {e}", path.display()))?; + debug!(hex = %hex(&sha), len = bytes.len(), path = %path.display(), "CAS store"); + } + Ok(sha) +} diff --git a/crates/ente-echo/Cargo.toml b/crates/ente-echo/Cargo.toml new file mode 100644 index 0000000..f5d3f7d --- /dev/null +++ b/crates/ente-echo/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ente-echo" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "ente_echo" +path = "src/lib.rs" + +[[bin]] +name = "ente-echo" +path = "src/main.rs" + +[dependencies] +ente-card = { path = "../ente-card" } +ente-bus = { path = "../ente-bus" } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/ente-echo/src/lib.rs b/crates/ente-echo/src/lib.rs new file mode 100644 index 0000000..8ebcc32 --- /dev/null +++ b/crates/ente-echo/src/lib.rs @@ -0,0 +1,18 @@ +//! Constantes públicas del Ente echo. Lib aparte del bin para que `busctl` +//! y otros consumidores puedan importar el InterfaceId sin enlazar el binario. + +use ente_card::{Capability, InterfaceId}; + +/// UUID estable del interface "echo". Genera nuevo por sed si forkeas. +pub const ECHO_IFACE: InterfaceId = InterfaceId([ + 0xec, 0x40, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x80, 0x00, 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, +]); +pub const ECHO_VERSION: u16 = 1; + +pub fn echo_capability() -> Capability { + Capability::Endpoint { + interface: ECHO_IFACE, + version: ECHO_VERSION, + } +} diff --git a/crates/ente-echo/src/main.rs b/crates/ente-echo/src/main.rs new file mode 100644 index 0000000..8ef07ea --- /dev/null +++ b/crates/ente-echo/src/main.rs @@ -0,0 +1,46 @@ +//! ente-echo: Ente proveedor mínimo. Anuncia Capability::Endpoint(ECHO) y +//! responde a invokes echando el blob recibido. Vehículo para validar el +//! forwarding bus → proveedor → bus → originator. + +use ente_bus::{BusResponse, BusServer, InvokeHandler}; +use ente_card::Capability; +use ente_echo::{echo_capability, ECHO_IFACE, ECHO_VERSION}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; + +struct EchoHandler; + +impl InvokeHandler for EchoHandler { + fn handle(&mut self, cap: Capability, blob: Vec) -> BusResponse { + match cap { + Capability::Endpoint { interface, version } + if interface == ECHO_IFACE && version == ECHO_VERSION => + { + let preview = String::from_utf8_lossy(&blob).into_owned(); + info!(text = %preview, len = blob.len(), "echo invoke"); + BusResponse::Invoked { result: blob } + } + other => { + warn!(?other, "ente-echo: capacidad no soportada"); + BusResponse::Error("ente-echo solo maneja ECHO_IFACE".into()) + } + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ente_echo=info")); + tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init(); + + info!("ente-echo arrancando"); + let mut server = BusServer::from_env().await?; + server.announce(vec![echo_capability()]).await?; + info!("Announce OK, sirviendo invokes"); + + if let Err(e) = server.serve(EchoHandler).await { + warn!(?e, "serve terminó"); + } + Ok(()) +} diff --git a/crates/ente-kernel/Cargo.toml b/crates/ente-kernel/Cargo.toml new file mode 100644 index 0000000..0edbc9a --- /dev/null +++ b/crates/ente-kernel/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ente-kernel" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +nix = { workspace = true } +libc = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/crates/ente-kernel/src/lib.rs b/crates/ente-kernel/src/lib.rs new file mode 100644 index 0000000..e849767 --- /dev/null +++ b/crates/ente-kernel/src/lib.rs @@ -0,0 +1,11 @@ +//! ente-kernel: primitivas de Linux que el Init usa pero que son reusables +//! desde tools/tests/sub-supervisores. Sin estado global. Cada función es +//! independiente y se puede testear de forma aislada. + +pub mod sigchld; +pub mod surface; +pub mod uevent; + +pub use sigchld::spawn_sigchld_stream; +pub use surface::{become_child_subreaper, bootstrap_kernel_surface}; +pub use uevent::{spawn_uevent_stream, UAction, UEvent}; diff --git a/crates/ente-kernel/src/sigchld.rs b/crates/ente-kernel/src/sigchld.rs new file mode 100644 index 0000000..e18ec66 --- /dev/null +++ b/crates/ente-kernel/src/sigchld.rs @@ -0,0 +1,66 @@ +//! SIGCHLD vía signalfd, no signal handler. +//! +//! Los handlers async-signal sólo pueden invocar funciones async-signal-safe +//! — no allocator, no `mpsc::send`. Con signalfd la señal entra al runtime de +//! Tokio como un `fd` legible y la cosechamos en el bucle como cualquier otro +//! evento. Esto es lo que hace que un init en Rust moderno sea sano. + +use anyhow::Context; +use nix::sys::signal::Signal; +use nix::sys::signalfd::{SfdFlags, SigSet, SignalFd}; +use std::os::fd::AsRawFd; +use tokio::io::unix::AsyncFd; +use tokio::sync::mpsc; +use tracing::{error, trace}; + +/// Bloquea SIGCHLD para entrega asíncrona, abre signalfd, y emite un `()` +/// en el canal cada vez que llega al menos una señal. +pub fn spawn_sigchld_stream() -> anyhow::Result> { + let mut mask = SigSet::empty(); + mask.add(Signal::SIGCHLD); + mask.thread_block().context("SIGCHLD thread_block")?; + + let sfd = SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC) + .context("signalfd creation")?; + + let async_fd = AsyncFd::new(SignalFdHandle(sfd)).context("AsyncFd::new")?; + + let (tx, rx) = mpsc::channel(8); + tokio::spawn(async move { + loop { + let mut guard = match async_fd.readable().await { + Ok(g) => g, + Err(e) => { error!(?e, "signalfd readable failed"); return; } + }; + // Drenamos todas las siginfos pendientes; signalfd las coalesce + // pero no las cuenta — un read por evento legible es suficiente. + drain(guard.get_inner()); + guard.clear_ready(); + if tx.send(()).await.is_err() { return; } + trace!("SIGCHLD batch coalesced"); + } + }); + + Ok(rx) +} + +struct SignalFdHandle(SignalFd); + +impl AsRawFd for SignalFdHandle { + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.0.as_raw_fd() + } +} + +fn drain(handle: &SignalFdHandle) { + let fd = handle.as_raw_fd(); + // Tamaño exacto de signalfd_siginfo. Leemos en bucle hasta EAGAIN. + let mut buf = [0u8; std::mem::size_of::()]; + loop { + let n = unsafe { + libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) + }; + if n < 0 { return; } + if n == 0 { return; } + } +} diff --git a/crates/ente-kernel/src/surface.rs b/crates/ente-kernel/src/surface.rs new file mode 100644 index 0000000..b4b2ff7 --- /dev/null +++ b/crates/ente-kernel/src/surface.rs @@ -0,0 +1,86 @@ +//! Bootstrap del entorno kernel para PID 1: monta procfs/sysfs/devtmpfs/cgroup2 +//! y registra al proceso como subreaper para adoptar huérfanos. +//! +//! Idempotente: si los puntos de montaje ya existen (initramfs los montó), +//! el segundo mount falla con EBUSY y simplemente lo ignoramos. + +use anyhow::Context; +use nix::mount::{mount, MsFlags}; +use tracing::debug; + +/// Monta los pseudo-filesystems esenciales. Errores benignos (ya montados) +/// se ignoran; errores serios se propagan. +pub fn bootstrap_kernel_surface() -> anyhow::Result<()> { + // Cada uno con sus flags estándar — NOSUID/NOEXEC/NODEV donde aplica. + mount::( + Some("proc"), "/proc", Some("proc"), + MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None, + ).ok(); + mount::( + Some("sysfs"), "/sys", Some("sysfs"), + MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None, + ).ok(); + mount::( + Some("devtmpfs"), "/dev", Some("devtmpfs"), + MsFlags::MS_NOSUID, None, + ).ok(); + mount::( + Some("cgroup2"), "/sys/fs/cgroup", Some("cgroup2"), + MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None, + ).ok(); + debug!("kernel surface bootstrap completo"); + Ok(()) +} + +/// PR_SET_CHILD_SUBREAPER: que adoptemos huérfanos del fractal. +/// +/// En PID 1 esto es redundante (el kernel ya lo hace), pero se deja explícito +/// para que ente-zero corriendo como sub-init en un container mantenga la +/// misma semántica. +pub fn become_child_subreaper() -> anyhow::Result<()> { + let r = unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1u64, 0u64, 0u64, 0u64) }; + if r != 0 { + anyhow::bail!( + "prctl PR_SET_CHILD_SUBREAPER falló: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) +} + +/// Cosechar zombis hasta vaciar la cola de niños muertos. Devuelve los +/// PIDs cosechados con su estado, como tuplas. +pub fn reap_all() -> Vec { + use nix::errno::Errno; + use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; + let mut out = Vec::new(); + loop { + match waitpid(None, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(pid, code)) => { + out.push(ReapedChild { pid: pid.as_raw(), status: ReapStatus::Exited(code) }); + } + Ok(WaitStatus::Signaled(pid, sig, _core)) => { + out.push(ReapedChild { pid: pid.as_raw(), status: ReapStatus::Signaled(sig as i32) }); + } + Ok(WaitStatus::StillAlive) => return out, + Err(Errno::ECHILD) => return out, + Err(_) => return out, + Ok(_) => continue, // Stopped/Continued — irrelevantes + } + } + // unreachable, satisface al borrow checker + #[allow(unreachable_code)] + out +} + +#[derive(Debug, Clone)] +pub struct ReapedChild { + pub pid: i32, + pub status: ReapStatus, +} + +#[derive(Debug, Clone)] +pub enum ReapStatus { + Exited(i32), + Signaled(i32), +} diff --git a/crates/ente-kernel/src/uevent.rs b/crates/ente-kernel/src/uevent.rs new file mode 100644 index 0000000..6e8f0fd --- /dev/null +++ b/crates/ente-kernel/src/uevent.rs @@ -0,0 +1,146 @@ +//! Stream de uevents del kernel vía NETLINK_KOBJECT_UEVENT. +//! +//! Bind requiere CAP_NET_ADMIN. En dev mode normal eso no está disponible — +//! el caller debe estar preparado para que `spawn_uevent_stream` falle, y +//! continuar sin grafo de dispositivos. + +use anyhow::Context; +use ente_card::DeviceClass; +use nix::sys::socket::{bind, socket, AddressFamily, NetlinkAddr, SockFlag, SockProtocol, SockType}; +use std::collections::HashMap; +use std::os::fd::{AsRawFd, OwnedFd}; +use tokio::io::unix::AsyncFd; +use tokio::sync::mpsc; +use tracing::{trace, warn}; + +#[derive(Debug, Clone)] +pub struct UEvent { + pub action: UAction, + pub devpath: String, + pub subsystem: Option, + pub device_class: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UAction { + Add, Remove, Change, Move, Online, Offline, Bind, Unbind, Unknown, +} + +pub fn spawn_uevent_stream() -> anyhow::Result> { + let fd: OwnedFd = socket( + AddressFamily::Netlink, + SockType::Datagram, + SockFlag::SOCK_NONBLOCK | SockFlag::SOCK_CLOEXEC, + SockProtocol::NetlinkKObjectUEvent, + ).context("netlink socket")?; + + // pid=0 → kernel asigna; groups=1 → multicast group del kernel uevent. + let addr = NetlinkAddr::new(0, 1); + bind(fd.as_raw_fd(), &addr).context("bind netlink uevent (CAP_NET_ADMIN?)")?; + + let async_fd = AsyncFd::new(NetlinkHandle(fd)).context("AsyncFd::new(netlink)")?; + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let mut guard = match async_fd.readable().await { + Ok(g) => g, + Err(e) => { warn!(?e, "netlink readable"); return; } + }; + let raw_fd = guard.get_inner().as_raw_fd(); + loop { + let n = unsafe { + libc::recv(raw_fd, buf.as_mut_ptr() as *mut _, buf.len(), 0) + }; + if n <= 0 { break; } + if let Some(evt) = parse_uevent(&buf[..n as usize]) { + trace!(?evt.action, devpath = %evt.devpath, "uevent"); + if tx.send(evt).await.is_err() { return; } + } + } + guard.clear_ready(); + } + }); + + Ok(rx) +} + +struct NetlinkHandle(OwnedFd); +impl AsRawFd for NetlinkHandle { + fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() } +} + +fn parse_uevent(buf: &[u8]) -> Option { + // udev re-emite mensajes con magic "libudev\0..." al multicast group 2. + // Si llegan al grupo 1 (improbable con bind groups=1) los filtramos igual. + if buf.starts_with(b"libudev\0") { + return None; + } + let mut parts = buf.split(|b| *b == 0).filter(|s| !s.is_empty()); + let header = std::str::from_utf8(parts.next()?).ok()?; + let (action_s, devpath) = header.split_once('@')?; + let mut env: HashMap = HashMap::new(); + for kv in parts { + if let Ok(s) = std::str::from_utf8(kv) { + if let Some((k, v)) = s.split_once('=') { + env.insert(k.to_string(), v.to_string()); + } + } + } + let subsystem = env.remove("SUBSYSTEM"); + let device_class = subsystem.as_deref().and_then(map_device_class); + Some(UEvent { + action: parse_action(action_s), + devpath: devpath.to_string(), + subsystem, + device_class, + }) +} + +fn parse_action(s: &str) -> UAction { + match s { + "add" => UAction::Add, + "remove" => UAction::Remove, + "change" => UAction::Change, + "move" => UAction::Move, + "online" => UAction::Online, + "offline" => UAction::Offline, + "bind" => UAction::Bind, + "unbind" => UAction::Unbind, + _ => UAction::Unknown, + } +} + +fn map_device_class(subsys: &str) -> Option { + Some(match subsys { + "block" => DeviceClass::Block, + "tty" => DeviceClass::Tty, + "input" => DeviceClass::Input, + "drm" => DeviceClass::Drm, + "net" => DeviceClass::Net, + "hidraw" => DeviceClass::Hidraw, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_uevent() { + let buf = b"add@/devices/foo\0ACTION=add\0DEVPATH=/devices/foo\0SUBSYSTEM=block\0"; + let evt = parse_uevent(buf).expect("parsed"); + assert_eq!(evt.action, UAction::Add); + assert_eq!(evt.devpath, "/devices/foo"); + assert_eq!(evt.subsystem.as_deref(), Some("block")); + assert!(matches!(evt.device_class, Some(DeviceClass::Block))); + } + + #[test] + fn libudev_messages_filtered() { + let buf = b"libudev\0\xfe\xed\xca\xfeadd@/devices/foo\0"; + assert!(parse_uevent(buf).is_none()); + } +} diff --git a/crates/ente-logind-compat/Cargo.toml b/crates/ente-logind-compat/Cargo.toml new file mode 100644 index 0000000..3a4f883 --- /dev/null +++ b/crates/ente-logind-compat/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ente-logind-compat" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "ente-logind-compat" +path = "src/main.rs" + +[dependencies] +ente-card = { path = "../ente-card" } +ente-bus = { path = "../ente-bus" } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +zbus = { version = "4", default-features = false, features = ["tokio"] } diff --git a/crates/ente-logind-compat/src/main.rs b/crates/ente-logind-compat/src/main.rs new file mode 100644 index 0000000..ce1736f --- /dev/null +++ b/crates/ente-logind-compat/src/main.rs @@ -0,0 +1,249 @@ +//! ente-logind-compat: el Ente que se hace pasar por systemd-logind. +//! +//! Vive FUERA de PID 1 — un parser D-Bus en el Init es una bomba con CVEs +//! históricos. Ejecutado como hijo del Ente #0 con `Restart` supervision. +//! +//! Implementa el subset del interface `org.freedesktop.login1.Manager` que +//! GNOME/KDE consultan en arranque. Cada método se traduce internamente en +//! una request al bus interno del fractal — capacidades tipadas, no nombres +//! D-Bus opacos hacia abajo. +//! +//! ## Lo que GNOME/KDE realmente llaman al boot +//! - ListSessions, ListUsers, GetSession* +//! - Inhibit (mantiene un fd vivo mientras la app está activa) +//! - CanPowerOff/CanReboot/CanSuspend +//! - PowerOff/Reboot/Suspend +//! - Properties: IdleHint, Docked, etc. +//! +//! El stub responde "no hay sesiones" y "sí puedo apagar" — suficiente para +//! que GNOME complete arranque sin marcar fallo. + +use ente_bus::{BusClient, BusRequest, BusResponse}; +use ente_card::Capability; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; +use tokio::signal::unix::{signal, SignalKind}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use zbus::{fdo, interface, zvariant::OwnedObjectPath, Connection}; + +const BUS_NAME: &str = "org.freedesktop.login1"; +const MANAGER_PATH: &str = "/org/freedesktop/login1"; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + init_tracing(); + info!("ente-logind-compat: arrancando"); + + let bus_addr = std::env::var("DBUS_SYSTEM_BUS_ADDRESS") + .unwrap_or_else(|_| "unix:path=/var/run/dbus/system_bus_socket".into()); + let bus_path = bus_addr.strip_prefix("unix:path=").unwrap_or(&bus_addr); + let bus_present = std::path::Path::new(bus_path).exists(); + info!(bus_addr, bus_present, "configuración D-Bus"); + + // Anunciamos nuestra presencia al bus interno del fractal antes de + // intentar registrar el nombre D-Bus. Esto sirve como handshake "estoy + // vivo" independiente del estado del system bus. + announce_to_fractal().await; + + if !bus_present { + warn!("system bus no disponible — modo idle (esperando SIGTERM)"); + return wait_for_term().await; + } + + let conn = match build_connection().await { + Ok(c) => c, + Err(e) => { + warn!(?e, "fallo al registrar org.freedesktop.login1 — modo idle"); + // No retornamos error: la supervisión Restart entraría en bucle + // si systemd-logind real ya posee el nombre. Esperar señal y salir. + return wait_for_term().await; + } + }; + + info!("logind compat corriendo — esperando señales"); + let _ = conn; // mantener viva la conexión hasta SIGTERM + wait_for_term().await +} + +async fn build_connection() -> anyhow::Result { + let manager = LogindManager::default(); + let conn = zbus::connection::Builder::system()? + .name(BUS_NAME)? + .serve_at(MANAGER_PATH, manager)? + .build() + .await?; + info!(name = BUS_NAME, path = MANAGER_PATH, "name acquired + manager served"); + Ok(conn) +} + +async fn announce_to_fractal() { + match BusClient::from_env().await { + Ok(mut client) => { + let req = BusRequest::Announce { + capabilities: vec![Capability::LegacyLogind], + }; + match client.call(req).await { + Ok(BusResponse::Ok) => info!("Announce → bus interno OK"), + Ok(other) => warn!(?other, "Announce respuesta inesperada"), + Err(e) => warn!(?e, "Announce falló"), + } + } + Err(e) => warn!(?e, "no se pudo conectar al bus interno"), + } +} + +async fn forward_to_fractal(req: BusRequest) -> fdo::Result<()> { + let mut client = BusClient::from_env().await + .map_err(|e| fdo::Error::Failed(format!("bus client: {e}")))?; + match client.call(req).await { + Ok(BusResponse::Ok) => Ok(()), + Ok(BusResponse::Error(s)) => Err(fdo::Error::Failed(s)), + Ok(other) => Err(fdo::Error::Failed(format!("respuesta inesperada: {other:?}"))), + Err(e) => Err(fdo::Error::Failed(format!("bus call: {e}"))), + } +} + +async fn wait_for_term() -> anyhow::Result<()> { + let mut term = signal(SignalKind::terminate())?; + let mut int_ = signal(SignalKind::interrupt())?; + let mut tick = tokio::time::interval(Duration::from_secs(60)); + tick.tick().await; // descartar el primer tick inmediato + loop { + tokio::select! { + _ = term.recv() => { info!("SIGTERM — cierre ordenado"); return Ok(()); } + _ = int_.recv() => { info!("SIGINT — cierre"); return Ok(()); } + _ = tick.tick() => { info!("heartbeat"); } + } + } +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ente_logind_compat=info")); + tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init(); +} + +#[derive(Default)] +struct LogindManager { + /// Contador monótono de inhibits. Real impl mantendría una tabla con + /// el fd vivo de cada uno y los enrutaría al bus interno del fractal. + inhibit_counter: AtomicU32, +} + +/// Tipos del wire format de `org.freedesktop.login1.Manager`. +type SessionTuple = (String, u32, String, String, OwnedObjectPath); +type UserTuple = (u32, String, OwnedObjectPath); + +#[interface(name = "org.freedesktop.login1.Manager")] +impl LogindManager { + // ---- Listado / lookup ---- + + async fn list_sessions(&self) -> fdo::Result> { + Ok(vec![]) + } + + async fn list_users(&self) -> fdo::Result> { + Ok(vec![]) + } + + async fn get_session(&self, _session_id: String) -> fdo::Result { + Err(fdo::Error::Failed("no sessions in fractal".into())) + } + + async fn get_session_by_pid(&self, _pid: u32) -> fdo::Result { + Err(fdo::Error::Failed("no sessions in fractal".into())) + } + + async fn get_user(&self, _uid: u32) -> fdo::Result { + Err(fdo::Error::Failed("no users in fractal".into())) + } + + async fn get_user_by_pid(&self, _pid: u32) -> fdo::Result { + Err(fdo::Error::Failed("no users in fractal".into())) + } + + // ---- Inhibit ---- + // + // Real: devuelve un fd que el cliente mantiene abierto mientras quiere + // inhibir. Cuando lo cierra, sabemos que terminó. Aquí: stub que falla + // con NotSupported — GNOME registra warning pero continúa el arranque. + + async fn inhibit( + &self, + what: String, + who: String, + why: String, + mode: String, + ) -> fdo::Result { + let n = self.inhibit_counter.fetch_add(1, Ordering::Relaxed); + info!(n, %what, %who, %why, %mode, "Inhibit (stub)"); + Err(fdo::Error::NotSupported("Inhibit todavía no enruta al bus interno".into())) + } + + // ---- Power management ---- + + async fn power_off(&self, interactive: bool) -> fdo::Result<()> { + info!(interactive, "PowerOff D-Bus → bus interno"); + forward_to_fractal(BusRequest::PowerOff { interactive }).await + } + + async fn reboot(&self, interactive: bool) -> fdo::Result<()> { + info!(interactive, "Reboot D-Bus → bus interno"); + forward_to_fractal(BusRequest::Reboot { interactive }).await + } + + async fn suspend(&self, interactive: bool) -> fdo::Result<()> { + info!(interactive, "Suspend D-Bus → bus interno"); + forward_to_fractal(BusRequest::Suspend { interactive }).await + } + + async fn hibernate(&self, interactive: bool) -> fdo::Result<()> { + info!(interactive, "Hibernate D-Bus → bus interno"); + forward_to_fractal(BusRequest::Hibernate { interactive }).await + } + + async fn can_power_off(&self) -> fdo::Result { + Ok("yes".into()) + } + + async fn can_reboot(&self) -> fdo::Result { + Ok("yes".into()) + } + + async fn can_suspend(&self) -> fdo::Result { + // "challenge" = válido, requiere autenticación. GNOME muestra el + // botón pero pide PIN/contraseña antes de invocar Suspend. + Ok("challenge".into()) + } + + async fn can_hibernate(&self) -> fdo::Result { + Ok("challenge".into()) + } + + // ---- Properties mínimas ---- + + #[zbus(property)] + async fn idle_hint(&self) -> bool { false } + + #[zbus(property)] + async fn idle_since_hint(&self) -> u64 { 0 } + + #[zbus(property)] + async fn idle_since_hint_monotonic(&self) -> u64 { 0 } + + #[zbus(property)] + async fn block_inhibited(&self) -> String { String::new() } + + #[zbus(property)] + async fn delay_inhibited(&self) -> String { String::new() } + + #[zbus(property)] + async fn docked(&self) -> bool { false } + + #[zbus(property)] + async fn lid_closed(&self) -> bool { false } + + #[zbus(property)] + async fn on_external_power(&self) -> bool { true } +} diff --git a/crates/ente-snapshot/Cargo.toml b/crates/ente-snapshot/Cargo.toml new file mode 100644 index 0000000..19d36b0 --- /dev/null +++ b/crates/ente-snapshot/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ente-snapshot" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +serde = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/ente-snapshot/src/lib.rs b/crates/ente-snapshot/src/lib.rs new file mode 100644 index 0000000..6eb0d1c --- /dev/null +++ b/crates/ente-snapshot/src/lib.rs @@ -0,0 +1,61 @@ +//! Persistencia del fractal. Captura el estado live (Cards encarnadas con +//! sus identidades preservadas) a un blob JSON. Al restaurar, las mismas +//! Ulids vuelven a la vida — los PIDs cambian (kernel no los preserva) pero +//! el grafo se reconstruye con la misma topología. +//! +//! Lo que NO se persiste: +//! - PIDs (irrelevantes tras reboot) +//! - bus_connections (runtime-only) +//! - pending_invokes (en vuelo, se descartan) +//! - device presence (uevents reconstruyen el índice) + +use ente_card::EntityCard; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ulid::Ulid; + +pub const SNAPSHOT_VERSION: u16 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FractalSnapshot { + pub version: u16, + pub timestamp_ms: u64, + pub seed_id: Ulid, + pub seed_label: String, + /// Cards live al momento del checkpoint, excluyendo la Semilla. + /// Al restaurar se inyectan en `genesis` con sus Ulids originales. + pub entes: Vec, +} + +impl FractalSnapshot { + pub fn write(&self, path: &Path) -> anyhow::Result<()> { + let bytes = serde_json::to_vec_pretty(self)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + // Escritura atómica: temp file + rename. + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + pub fn read(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path)?; + let snap: FractalSnapshot = serde_json::from_slice(&bytes)?; + if snap.version != SNAPSHOT_VERSION { + anyhow::bail!( + "snapshot version {} no soportada (esperada {})", + snap.version, SNAPSHOT_VERSION + ); + } + Ok(snap) + } +} + +pub fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/crates/ente-soma/Cargo.toml b/crates/ente-soma/Cargo.toml new file mode 100644 index 0000000..63df673 --- /dev/null +++ b/crates/ente-soma/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ente-soma" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +ente-bus = { path = "../ente-bus" } +nix = { workspace = true } +libc = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/crates/ente-soma/src/lib.rs b/crates/ente-soma/src/lib.rs new file mode 100644 index 0000000..e3e8c44 --- /dev/null +++ b/crates/ente-soma/src/lib.rs @@ -0,0 +1,357 @@ +//! Encarnación del Soma: traducción de SomaSpec a syscalls. +//! +//! Esta capa es la única parte de PID 1 que toca syscalls de namespacing — +//! todo lo demás opera sobre tipos de alto nivel. La complejidad vive aquí +//! por diseño: encapsulada, auditable, y con un único punto de entrada. +//! +//! ## Protocolo padre↔hijo en el path namespaced +//! +//! ```text +//! parent child +//! | | +//! |--- clone() ------->| (child empieza dentro de los nuevos NS) +//! | | +//! | |---- read(sync_r, 1) ---- (bloquea) +//! | | +//! | write uid_map | +//! | write gid_map | +//! | cgroup move | +//! | cpu affinity | +//! | | +//! |--- write(sync_w) ->| +//! | |---- setrlimit +//! | |---- mount(/, MS_PRIVATE | MS_REC) +//! | |---- execve() +//! ``` + +use ente_card::{CgroupSpec, EntityCard, NamespaceSet, Payload, ResourceLimits}; +use nix::fcntl::OFlag; +use nix::sched::CloneFlags; +use nix::unistd::{pipe2, Pid}; +use std::ffi::CString; +use std::os::fd::{AsRawFd, IntoRawFd, RawFd}; +use std::process::Command; +use std::sync::OnceLock; +use tracing::{info, warn}; + +/// Path del socket del bus interno. Se establece una sola vez al arrancar +/// PID 1 (después de que el listener bind exitoso). Cada hijo encarnado +/// recibe este path en `ENTE_BUS_SOCK`. +static BUS_SOCK_PATH: OnceLock = OnceLock::new(); + +pub fn set_bus_sock(path: String) { + let _ = BUS_SOCK_PATH.set(path); +} + +fn build_env(card: &EntityCard, base_envp: &[(String, String)]) -> Vec<(String, String)> { + // Heredamos parent env, sobreescribimos con el envp explícito de la Card, + // y al final inyectamos las vars del fractal (no negociables). + let mut env: Vec<(String, String)> = std::env::vars().collect(); + for (k, v) in base_envp { + env.retain(|(ek, _)| ek != k); + env.push((k.clone(), v.clone())); + } + if let Some(p) = BUS_SOCK_PATH.get() { + env.retain(|(k, _)| k != ente_bus::ENV_BUS_SOCK); + env.push((ente_bus::ENV_BUS_SOCK.into(), p.clone())); + } + env.retain(|(k, _)| k != ente_bus::ENV_ENTE_ID); + env.push((ente_bus::ENV_ENTE_ID.into(), card.id.to_string())); + env +} + +pub fn incarnate(card: &EntityCard) -> anyhow::Result { + if needs_namespacing(&card.soma.namespaces) { + incarnate_namespaced(card) + } else { + incarnate_plain(card) + } +} + +fn needs_namespacing(ns: &NamespaceSet) -> bool { + ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup +} + +/// Path simple: para Entes que no requieren aislamiento. Útil para Entes-shim +/// que conviven con el host (e.g. compat-logind) y para dev mode. +fn incarnate_plain(card: &EntityCard) -> anyhow::Result { + let (exec, argv, base_envp) = match &card.payload { + Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), + Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), + _ => anyhow::bail!("incarnate_plain: payload no ejecutable"), + }; + let env = build_env(card, &base_envp); + let mut cmd = Command::new(&exec); + cmd.args(&argv); + cmd.env_clear(); + for (k, v) in &env { + cmd.env(k, v); + } + let child = cmd.spawn().map_err(|e| anyhow::anyhow!("spawn {exec}: {e}"))?; + Ok(Pid::from_raw(child.id() as i32)) +} + +/// Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo. +fn incarnate_namespaced(card: &EntityCard) -> anyhow::Result { + let flags = build_clone_flags(&card.soma.namespaces); + info!(label = %card.label, ?flags, "namespaced incarnation"); + + let (exec, argv, base_envp) = match &card.payload { + Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), + Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), + _ => anyhow::bail!("incarnate_namespaced: payload no ejecutable"), + }; + + // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup. + // O_CLOEXEC garantiza que el fd se cierra automáticamente en execve, así + // no contamina el binario final. + let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC)?; + let sync_r_raw: RawFd = sync_r.into_raw_fd(); + let sync_w_raw: RawFd = sync_w.into_raw_fd(); + + let exec_c = CString::new(exec.clone())?; + let argv_c: Vec = std::iter::once(exec_c.clone()) + .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok())) + .collect(); + let argv_ptrs: Vec<*const libc::c_char> = argv_c.iter() + .map(|c| c.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect(); + + // envp construido pre-clone: padre y hijo comparten el COW. Tras execve + // el kernel reemplaza el address space, así que las CStrings sólo viven + // hasta el syscall. + let env_pairs = build_env(card, &base_envp); + let envp_c: Vec = env_pairs.iter() + .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok()) + .collect(); + let envp_ptrs: Vec<*const libc::c_char> = envp_c.iter() + .map(|c| c.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect(); + + let rlimits = card.soma.rlimits.clone(); + let mount_ns_enabled = card.soma.namespaces.mount; + + // SAFETY: la clausura corre en stack nuevo dentro de un proceso recién + // clonado, COW del padre. Reglas inviolables: + // - sólo syscalls async-signal-safe + // - no `println!`/`tracing!`/cualquier I/O del runtime + // - no allocator (vec/box/string) + // - no Drop con efectos + // - capturar sólo Copy o datos pre-construidos + let cb = Box::new(move || -> isize { + // 1) Cerrar el extremo de escritura: pertenece al padre. + unsafe { libc::close(sync_w_raw); } + + // 2) Bloquear hasta que el padre termine el setup (uid_map, cgroup, etc). + let mut byte = [0u8; 1]; + let n = unsafe { + libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1) + }; + if n != 1 { unsafe { libc::_exit(101); } } + unsafe { libc::close(sync_r_raw); } + + // 3) Aplicar rlimits dentro del nuevo namespace. + unsafe { apply_rlimits_unchecked(&rlimits); } + + // 4) Si tenemos mount ns, marcar / como privado recursivamente para + // que mounts del Ente no se filtren al host (es la trampa más + // típica al delegar mount ns). + if mount_ns_enabled { + unsafe { + libc::mount( + std::ptr::null(), + b"/\0".as_ptr() as *const _, + std::ptr::null(), + libc::MS_PRIVATE | libc::MS_REC, + std::ptr::null(), + ); + } + } + + // 5) execve. Si retorna, falló. + unsafe { + libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()); + libc::_exit(102); + } + }); + + let mut stack = vec![0u8; 1024 * 1024]; + + #[allow(deprecated)] + let pid = unsafe { + nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD)) + }.map_err(|e| { + unsafe { libc::close(sync_r_raw); libc::close(sync_w_raw); } + anyhow::anyhow!("clone failed: {e}") + })?; + + // Padre: cerrar el extremo de lectura. + unsafe { libc::close(sync_r_raw); } + + // Setup post-clone en padre. Errores aquí no son fatales — registramos y + // continuamos. Si algo crítico falla, el hijo execve seguirá adelante con + // configuración degradada y el supervisor decidirá qué hacer. + if let Err(e) = configure_child(pid, card) { + warn!(?e, ?pid, "configure_child errores no-fatales"); + } + + // Despertar al hijo. + let signal_byte = [b'x']; + let written = unsafe { + libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1) + }; + unsafe { libc::close(sync_w_raw); } + if written != 1 { + warn!(?pid, "no se pudo señalizar al hijo (write devolvió {})", written); + } + + if matches!(&card.payload, Payload::Legacy { fakes, .. } if !fakes.is_empty()) { + // TODO: facades viven en un Ente-shim aparte que se inyecta vía + // bind-mount sobre /run/systemd/notify, /run/dbus/system_bus_socket, + // etc. Cuando exista, registrarlas aquí. + warn!("legacy facades declaradas pero shim post-clone no implementado"); + } + + Ok(pid) +} + +/// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move. +/// Estos archivos en /proc//* tienen reglas de propiedad que sólo el +/// padre puede satisfacer mientras el hijo está suspendido en el sync pipe. +fn configure_child(pid: Pid, card: &EntityCard) -> anyhow::Result<()> { + if card.soma.namespaces.user { + // Desde kernel 3.19 se debe escribir "deny" a setgroups antes de + // poder escribir gid_map sin CAP_SETGID. Ignorar errores: en kernels + // antiguos el archivo no existe y no es problema. + let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny"); + + let uid = nix::unistd::getuid().as_raw(); + let gid = nix::unistd::getgid().as_raw(); + std::fs::write( + format!("/proc/{}/uid_map", pid.as_raw()), + format!("0 {uid} 1"), + ).map_err(|e| anyhow::anyhow!("write uid_map: {e}"))?; + std::fs::write( + format!("/proc/{}/gid_map", pid.as_raw()), + format!("0 {gid} 1"), + ).map_err(|e| anyhow::anyhow!("write gid_map: {e}"))?; + } + + if !card.soma.cgroup.path.is_empty() { + match ensure_cgroup(&card.soma.cgroup) { + Ok(abs_path) => { + let procs = format!("{abs_path}/cgroup.procs"); + if let Err(e) = std::fs::write(&procs, format!("{}\n", pid.as_raw())) { + warn!(?e, path = %procs, "cgroup move falló"); + } + } + Err(e) => warn!(?e, path = %card.soma.cgroup.path, "ensure_cgroup falló"), + } + } + + if let Some(cpus) = &card.soma.cpu_affinity { + if let Err(e) = set_cpu_affinity(pid, cpus) { + warn!(?e, ?pid, "sched_setaffinity falló"); + } + } + + Ok(()) +} + +fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> anyhow::Result<()> { + let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() }; + unsafe { libc::CPU_ZERO(&mut set); } + for &c in cpus { + unsafe { libc::CPU_SET(c as usize, &mut set); } + } + let r = unsafe { + libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::(), &set) + }; + if r != 0 { + anyhow::bail!("sched_setaffinity: {}", std::io::Error::last_os_error()); + } + Ok(()) +} + +/// SAFETY: invocada en el hijo post-clone, sólo libc, no Rust I/O. +unsafe fn apply_rlimits_unchecked(rl: &ResourceLimits) { + if let Some(mem) = rl.mem_bytes { + let lim = libc::rlimit { rlim_cur: mem, rlim_max: mem }; + libc::setrlimit(libc::RLIMIT_AS, &lim); + } + if let Some(np) = rl.nproc { + let lim = libc::rlimit { rlim_cur: np as u64, rlim_max: np as u64 }; + libc::setrlimit(libc::RLIMIT_NPROC, &lim); + } + if let Some(nf) = rl.nofile { + let lim = libc::rlimit { rlim_cur: nf as u64, rlim_max: nf as u64 }; + libc::setrlimit(libc::RLIMIT_NOFILE, &lim); + } +} + +/// Cgroup actual del proceso PID 1 (o ente-zero en dev). Lo usamos como +/// prefijo para paths declarados relativos en CgroupSpec.path. En prod (PID 1 +/// como child del kernel) será `/`. En dev bajo systemd-user será algo como +/// `/user.slice/user-1001.slice/user@1001.service/...`. +fn current_cgroup() -> Option { + let s = std::fs::read_to_string("/proc/self/cgroup").ok()?; + // Formato unified (cgroup v2): "0::/user.slice/..." + s.lines() + .find_map(|l| l.strip_prefix("0::")) + .map(|s| s.trim().to_string()) +} + +/// Resuelve un path declarado en CgroupSpec contra la jerarquía real. +/// - path absoluto (empieza con `/`): respetar tal cual +/// - path relativo: prefijar con cgroup actual de PID 1 +fn resolve_cgroup_path(spec_path: &str) -> String { + if spec_path.is_empty() { return String::new(); } + if spec_path.starts_with('/') { + return spec_path.to_string(); + } + let trimmed = spec_path.trim_start_matches('/'); + if let Some(cg) = current_cgroup() { + let base = if cg == "/" { String::new() } else { cg.trim_end_matches('/').to_string() }; + format!("{base}/{trimmed}") + } else { + format!("/{trimmed}") + } +} + +/// Crea el cgroup declarado, aplica weights. Devuelve el path absoluto +/// resultante bajo /sys/fs/cgroup. +fn ensure_cgroup(spec: &CgroupSpec) -> anyhow::Result { + let rel = resolve_cgroup_path(&spec.path); + if rel.is_empty() { + anyhow::bail!("cgroup path vacío"); + } + let abs = format!("/sys/fs/cgroup{}", rel); + std::fs::create_dir_all(&abs) + .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", abs))?; + if let Some(w) = spec.cpu_weight { + let _ = std::fs::write(format!("{abs}/cpu.weight"), format!("{w}\n")); + } + if let Some(w) = spec.io_weight { + // io.weight requiere el formato "default " en cgroup v2. + let _ = std::fs::write(format!("{abs}/io.weight"), format!("default {w}\n")); + } + Ok(abs) +} + +fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags { + let mut f = CloneFlags::empty(); + if ns.mount { f |= CloneFlags::CLONE_NEWNS; } + if ns.pid { f |= CloneFlags::CLONE_NEWPID; } + if ns.net { f |= CloneFlags::CLONE_NEWNET; } + if ns.uts { f |= CloneFlags::CLONE_NEWUTS; } + if ns.ipc { f |= CloneFlags::CLONE_NEWIPC; } + if ns.user { f |= CloneFlags::CLONE_NEWUSER; } + if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; } + f +} + +// AsRawFd unused but keep the import alive — soma may grow more fd handling. +#[allow(dead_code)] +fn _keep_imports(_: &dyn AsRawFd) {} diff --git a/crates/ente-wasm/Cargo.toml b/crates/ente-wasm/Cargo.toml new file mode 100644 index 0000000..c22cf7e --- /dev/null +++ b/crates/ente-wasm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ente-wasm" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ente-card = { path = "../ente-card" } +wasmi = { workspace = true } +wat = { workspace = true } +ulid = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/crates/ente-wasm/src/lib.rs b/crates/ente-wasm/src/lib.rs new file mode 100644 index 0000000..27b5953 --- /dev/null +++ b/crates/ente-wasm/src/lib.rs @@ -0,0 +1,118 @@ +//! Encarnación de Payload::Wasm vía wasmi. +//! +//! Cada Ente Wasm corre en un hilo dedicado (wasmi es síncrono) que se +//! comunica con el grafo vía un identificador propio. El thread::JoinHandle +//! se descarta — el ciclo de vida del Wasm se controla por su `entry` +//! function: cuando retorna, el Ente se considera disuelto. +//! +//! ## Host imports expuestos +//! - `ente.log(ptr: i32, len: i32)` imprime una string UTF-8 +//! - `ente.exit(code: i32)` solicita salida del Ente +//! +//! Más adelante: `ente.bus_call`, `ente.cap_invoke`, etc. + +use ente_card::EntityCard; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::Arc; +use tracing::{error, info, warn}; +use wasmi::{Caller, Engine, Linker, Memory, Module, Store}; + +/// Estado por instancia Wasm. Se accede tanto desde host imports (vía +/// `Caller::data()`) como desde el thread runner para estado de salida. +pub struct WasmEnte { + pub id: ulid::Ulid, + pub label: String, + pub exit_code: Arc, +} + +/// Encarna un payload Wasm en un hilo dedicado. Devuelve un identificador +/// no-PID que el grafo trata como Ente Virtual con cuerpo de cómputo. +pub fn incarnate_wasm(card: &EntityCard, module_bytes: Vec, entry: String) -> anyhow::Result<()> { + let label = card.label.clone(); + let id = card.id; + let exit_code = Arc::new(AtomicI32::new(0)); + let exit_code_handle = exit_code.clone(); + + std::thread::Builder::new() + .name(format!("wasm-{label}")) + .spawn(move || { + if let Err(e) = run_wasm(WasmEnte { id, label: label.clone(), exit_code: exit_code_handle.clone() }, &module_bytes, &entry) { + error!(?e, %label, "Wasm ente terminó con error"); + exit_code_handle.store(-1, Ordering::Relaxed); + } + })?; + Ok(()) +} + +fn run_wasm(ente: WasmEnte, module_bytes: &[u8], entry: &str) -> anyhow::Result<()> { + let engine = Engine::default(); + let module = Module::new(&engine, module_bytes) + .map_err(|e| anyhow::anyhow!("Wasm module compile: {e}"))?; + let mut store = Store::new(&engine, ente); + let mut linker = >::new(&engine); + + linker.func_wrap("ente", "log", |caller: Caller<'_, WasmEnte>, ptr: i32, len: i32| { + host_log(caller, ptr, len); + })?; + + linker.func_wrap("ente", "exit", |mut caller: Caller<'_, WasmEnte>, code: i32| { + caller.data_mut().exit_code.store(code, Ordering::Relaxed); + })?; + + let pre = linker.instantiate(&mut store, &module) + .map_err(|e| anyhow::anyhow!("Wasm instantiate: {e}"))?; + let instance = pre.start(&mut store) + .map_err(|e| anyhow::anyhow!("Wasm start: {e}"))?; + + let func = instance.get_typed_func::<(), ()>(&store, entry) + .map_err(|e| anyhow::anyhow!("Wasm get_func {entry}: {e}"))?; + + info!(label = %store.data().label, %entry, "Wasm ente ejecutando"); + func.call(&mut store, ()).map_err(|e| anyhow::anyhow!("Wasm call {entry}: {e}"))?; + let code = store.data().exit_code.load(Ordering::Relaxed); + info!(label = %store.data().label, code, "Wasm ente terminó"); + Ok(()) +} + +fn host_log(caller: Caller<'_, WasmEnte>, ptr: i32, len: i32) { + let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) { + Some(m) => m, + None => { + warn!("Wasm ente sin memoria exportada — log ignorado"); + return; + } + }; + let data = read_memory(&caller, memory, ptr, len); + match std::str::from_utf8(&data) { + Ok(s) => info!(label = %caller.data().label, "[wasm] {s}"), + Err(_) => warn!(label = %caller.data().label, "Wasm log con bytes no UTF-8"), + } +} + +fn read_memory(caller: &Caller<'_, WasmEnte>, memory: Memory, ptr: i32, len: i32) -> Vec { + let ptr = ptr.max(0) as usize; + let len = len.max(0) as usize; + let data = memory.data(caller); + if ptr.saturating_add(len) > data.len() { + return Vec::new(); + } + data[ptr..ptr + len].to_vec() +} + +/// Módulo WAT mínimo de demostración. Llama a `ente.log` con "hola fractal". +/// Compilado a binario Wasm en runtime con `wat`. +pub fn demo_module_bytes() -> anyhow::Result> { + let wat = r#" +(module + (import "ente" "log" (func $log (param i32 i32))) + (import "ente" "exit" (func $exit (param i32))) + (memory (export "memory") 1) + (data (i32.const 0) "hola fractal desde wasm") + (func (export "_start") + (call $log (i32.const 0) (i32.const 23)) + (call $exit (i32.const 0)) + ) +) +"#; + Ok(wat::parse_str(wat)?) +} diff --git a/crates/ente-zero/Cargo.toml b/crates/ente-zero/Cargo.toml new file mode 100644 index 0000000..2992155 --- /dev/null +++ b/crates/ente-zero/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "ente-zero" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "ente-zero" +path = "src/main.rs" + +[dependencies] +# Lib crates del fractal — orden: contratos → infra → encarnación → orquestación +ente-card = { path = "../ente-card" } +ente-bus = { path = "../ente-bus" } +ente-cas = { path = "../ente-cas" } +ente-kernel = { path = "../ente-kernel" } +ente-soma = { path = "../ente-soma" } +ente-wasm = { path = "../ente-wasm" } +ente-snapshot = { path = "../ente-snapshot" } +ente-brain = { path = "../ente-brain" } +ente-echo = { path = "../ente-echo" } # solo para constantes del demo + +# Runtime / utilidades de PID 1 +serde = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } +tokio = { workspace = true } +nix = { workspace = true } +libc = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/ente-zero/src/brain_glue.rs b/crates/ente-zero/src/brain_glue.rs new file mode 100644 index 0000000..261afe5 --- /dev/null +++ b/crates/ente-zero/src/brain_glue.rs @@ -0,0 +1,129 @@ +//! Glue entre el bucle primordial y `ente-brain`. +//! +//! Tres responsabilidades: +//! 1. Traducir eventos del grafo (`GraphEvent`) a `ente_brain::EventKind` +//! + `SubjectInfo` para el observador y el motor. +//! 2. Implementar `ActionSink` para que las Acciones del cerebro tengan +//! un canal de salida hacia el grafo (Spawn → SpawnRequest, etc.). +//! 3. Encapsular el snapshot de SubjectInfo desde el grafo sin filtrar +//! detalles internos al cerebro. + +use crate::events::GraphEvent; +use crate::graph::EnteGraph; +use ente_brain::{ActionSink, EventKind as BrainEventKind, SubjectInfo}; +use ente_card::Capability; +use serde::Deserialize; +use tokio::sync::mpsc; +use tracing::warn; +use ulid::Ulid; + +/// Traduce un GraphEvent a (EventKind, SubjectInfo) para alimentar el cerebro. +/// +/// Devuelve `None` para eventos puramente internos del bus (Response, Close) +/// que no son interesantes para reglas o estadística. +pub fn graph_event_to_brain<'a>( + evt: &'a GraphEvent, + graph: &EnteGraph, +) -> Option<(BrainEventKind, SubjectInfo)> { + match evt { + GraphEvent::EnteDied { id, .. } => { + Some((BrainEventKind::EnteDied, subject_info_for(graph, *id))) + } + GraphEvent::SpawnRequest { card, .. } => { + // El "sujeto" del spawn es el child que va a nacer. + let info = SubjectInfo { + id: Some(card.id), + label: Some(card.label.clone()), + capabilities: card.provides.iter().cloned().collect(), + }; + Some((BrainEventKind::EnteSpawned, info)) + } + GraphEvent::BusRequest { from, request, .. } => { + let kind = match request { + ente_bus::BusRequest::Announce { .. } => BrainEventKind::BusAnnounce, + ente_bus::BusRequest::Invoke { cap, .. } => { + BrainEventKind::BusInvokeOf(cap.clone()) + } + _ => BrainEventKind::BusInvoke, + }; + let info = match from { + Some(id) => subject_info_for(graph, *id), + None => SubjectInfo::default(), + }; + Some((kind, info)) + } + GraphEvent::CapabilityRequested { from, .. } => { + Some((BrainEventKind::BusInvoke, subject_info_for(graph, *from))) + } + // Responses, ConnClosed, Shutdown — irrelevantes para reglas + _ => None, + } +} + +fn subject_info_for(graph: &EnteGraph, id: Ulid) -> SubjectInfo { + // Acceso de sólo lectura — usamos el método público lookup_pid + cards + // virtuales en el grafo. Si el Ente no existe (ya disuelto), info vacía. + if let Some(card) = graph.card_for(&id) { + SubjectInfo { + id: Some(id), + label: Some(card.label.clone()), + capabilities: card.provides.iter().cloned().collect(), + } + } else { + SubjectInfo { id: Some(id), label: None, capabilities: Vec::new() } + } +} + +/// `ActionSink` que enruta acciones del cerebro al bucle primordial. +pub struct GraphSink { + pub graph_tx: mpsc::Sender, + pub requester: Ulid, +} + +impl ActionSink for GraphSink { + fn spawn(&self, card_blob: &str) { + // El blob es JSON de EntityCard. + match serde_json::from_str::(card_blob) { + Ok(card) => { + let evt = GraphEvent::SpawnRequest { card, requester: self.requester }; + if self.graph_tx.try_send(evt).is_err() { + warn!("brain spawn: graph_tx lleno o cerrado"); + } + } + Err(e) => warn!(?e, "brain spawn: blob no parseable como EntityCard JSON"), + } + } + + fn invoke(&self, target_cap: Capability, blob: Vec) { + // Sin BusClient en proceso — el sink registra la intención. Una mejora + // futura: spawn un BusClient::connect + call. Por ahora log estructurado. + warn!(?target_cap, blob_len = blob.len(), "brain invoke: no bus client en glue (TODO)"); + } + + fn notify(&self, target_id: Ulid, message: &str) { + warn!(%target_id, %message, "brain notify: no implementado en glue"); + } + + fn inhibit(&self, reason: &str) { + warn!(%reason, "brain inhibit: no implementado en glue"); + } +} + +/// Helper para que el grafo exponga la Card de un Ente vivo. Lo añadimos como +/// trait extension porque graph::EnteGraph mantiene `incarnated` privado. +pub trait GraphCardLookup { + fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard>; +} + +impl GraphCardLookup for EnteGraph { + fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard> { + // Acceso vía método público que añadiremos en graph/mod.rs. + self.peek_card(id) + } +} + +// Eliminar el campo `_unused` que rustc puede quejarse — placeholder para +// evitar warning si algún field queda sin uso. +#[allow(dead_code)] +#[derive(Deserialize)] +struct _Touch {} diff --git a/crates/ente-zero/src/bus.rs b/crates/ente-zero/src/bus.rs new file mode 100644 index 0000000..6086b79 --- /dev/null +++ b/crates/ente-zero/src/bus.rs @@ -0,0 +1,143 @@ +//! Listener del bus interno. Vive en PID 1, acepta conexiones de Entes hijos, +//! extrae credenciales del kernel vía SO_PEERCRED, y enruta cada request al +//! grafo. Conexión bidireccional: el grafo puede *empujar* requests hacia +//! una conexión registrada (forwarding de Invoke al proveedor). +//! +//! ## Por qué bidireccional +//! +//! Un Ente que provee `Capability::Endpoint` debe poder *recibir* invokes +//! sin abrir más sockets. Después de Announce, el grafo guarda el lado de +//! escritura de su conexión y lo usa para forwardear. + +use crate::events::GraphEvent; +use ente_bus::{read_frame, write_frame, BusMessage, BusPayload, BusResponse, PeerCreds}; +use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; +use std::path::PathBuf; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{error, info, trace, warn}; +use ulid::Ulid; + +pub fn default_socket_path() -> PathBuf { + if let Ok(p) = std::env::var(ente_bus::ENV_BUS_SOCK) { + return p.into(); + } + let runtime = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into())); + let user = std::env::var("USER").unwrap_or_else(|_| "ente".into()); + format!("{runtime}/ente-bus-{user}.sock").into() +} + +pub fn spawn_bus(path: PathBuf, graph_tx: mpsc::Sender) -> anyhow::Result { + let _ = std::fs::remove_file(&path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let listener = UnixListener::bind(&path)?; + info!(path = %path.display(), "bus interno escuchando"); + + let path_returned = path.clone(); + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let tx = graph_tx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_conn(stream, tx).await { + warn!(?e, "bus connection ended"); + } + }); + } + Err(e) => { + error!(?e, "bus accept failed, listener cerrando"); + return; + } + } + } + }); + + Ok(path_returned) +} + +async fn handle_conn(stream: UnixStream, graph_tx: mpsc::Sender) -> anyhow::Result<()> { + // SO_PEERCRED: el kernel adjunta pid/uid/gid al socket en connect/accept. + // No-falsificable desde el cliente. + let creds = getsockopt(&stream, PeerCredentials) + .map_err(|e| anyhow::anyhow!("getsockopt PEERCRED: {e}"))?; + let peer = PeerCreds { + pid: creds.pid(), + uid: creds.uid(), + gid: creds.gid(), + }; + trace!(?peer, "bus conn aceptada"); + + let (mut reader, mut writer) = stream.into_split(); + let (out_tx, mut out_rx) = mpsc::channel::(64); + + // Writer task: única vía de escritura al socket. Multiplexa entre + // respuestas a peticiones del cliente y forwards iniciados por el grafo. + let writer_task = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if let Err(e) = write_frame(&mut writer, &msg).await { + warn!(?e, "bus writer falló, terminando"); + return; + } + } + }); + + let mut announced_id: Option = None; + let result: anyhow::Result<()> = (async { + loop { + let msg = match read_frame(&mut reader).await { + Ok(m) => m, + Err(e) => { + trace!(?e, "bus conn read terminó"); + return Ok(()); + } + }; + match msg.payload { + BusPayload::Request(req) => { + let is_announce = matches!(req, ente_bus::BusRequest::Announce { .. }); + let (reply_tx, reply_rx) = oneshot::channel(); + if graph_tx.send(GraphEvent::BusRequest { + peer, + from: msg.from, + request: req, + outbound: out_tx.clone(), + reply: reply_tx, + }).await.is_err() { + warn!("graph cerrado, terminando bus connection"); + return Ok(()); + } + let response = reply_rx.await.unwrap_or_else(|_| { + BusResponse::Error("graph dropped reply channel".into()) + }); + if is_announce && matches!(response, BusResponse::Ok) { + // Auth del Announce ya fue verificada por el grafo; + // memorizamos para cleanup en cierre. + announced_id = msg.from; + } + let out = BusMessage { + from: None, + seq: msg.seq, + payload: BusPayload::Response(response), + }; + if out_tx.send(out).await.is_err() { return Ok(()); } + } + BusPayload::Response(resp) => { + // Respuesta a un Invoke que el grafo forwardeó a este peer. + let _ = graph_tx.send(GraphEvent::BusResponse { + seq: msg.seq, + response: resp, + }).await; + } + } + } + }).await; + + if let Some(id) = announced_id { + let _ = graph_tx.send(GraphEvent::BusConnClosed { ente_id: Some(id) }).await; + } + writer_task.abort(); + result +} diff --git a/crates/ente-zero/src/events.rs b/crates/ente-zero/src/events.rs new file mode 100644 index 0000000..a9ed86f --- /dev/null +++ b/crates/ente-zero/src/events.rs @@ -0,0 +1,55 @@ +//! Eventos internos del bucle primordial. Todo cambio de estado del fractal +//! pasa por aquí — la única vía de mutación del grafo desde tasks externas. + +use ente_bus::{BusMessage, BusRequest, BusResponse, PeerCreds}; +use ente_card::{Capability, EntityCard}; +use nix::sys::signal::Signal; +use tokio::sync::{mpsc, oneshot}; +use ulid::Ulid; + +#[derive(Debug)] +pub enum GraphEvent { + EnteDied { id: Ulid, status: ExitStatus }, + CapabilityRequested { + from: Ulid, + cap: Capability, + reply: oneshot::Sender, + }, + SpawnRequest { card: EntityCard, requester: Ulid }, + /// Request del bus interno. `peer` es no-falsificable (kernel-injected + /// via SO_PEERCRED). `from` es la identidad reclamada por el cliente — + /// el grafo la verifica contra `peer.pid`. + BusRequest { + peer: PeerCreds, + from: Option, + request: BusRequest, + outbound: mpsc::Sender, + reply: oneshot::Sender, + }, + /// Response a un Invoke forwardeado por el grafo a un proveedor. + /// `seq` debe coincidir con una entry en pending_invokes. + BusResponse { seq: u64, response: BusResponse }, + /// Cliente del bus cerró su conexión. Si había anunciado identidad, + /// el grafo retira esa conexión del registry. + BusConnClosed { ente_id: Option }, + Shutdown { reason: ShutdownReason }, +} + +#[derive(Debug, Clone)] +pub enum ExitStatus { + Exit(i32), + Killed(Signal), +} + +#[derive(Debug, Clone)] +pub enum ShutdownReason { + SeedRequested, + Signal(Signal), +} + +#[derive(Debug)] +pub enum CapabilityGrant { + Granted { token: u64 }, + NoProvider, + Denied { reason: &'static str }, +} diff --git a/crates/ente-zero/src/graph/bus_mediator.rs b/crates/ente-zero/src/graph/bus_mediator.rs new file mode 100644 index 0000000..c5d8a0d --- /dev/null +++ b/crates/ente-zero/src/graph/bus_mediator.rs @@ -0,0 +1,179 @@ +//! Bus mediator: integración de `EnteGraph` con el bus interno. +//! +//! Responsabilidades: +//! - Auth de Announce (verificar identidad reclamada contra SO_PEERCRED) +//! - Registro de conexiones (`bus_connections` indexado por Ulid) +//! - Forwarding de Invokes a proveedores +//! - Tracking de invokes en vuelo (`pending_invokes` por seq) +//! - Cleanup en cierre de conexión + +use super::{EnteGraph, SERVER_SEQ_FLAG}; +use ente_bus::{BusMessage, BusPayload, BusRequest, BusResponse, EnteInfo, PeerCreds}; +use ente_card::Capability; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, info, warn}; +use ulid::Ulid; + +/// Operaciones que requieren identidad verificada en el bus. +/// +/// Sólo `Announce`: establece la entrada en `bus_connections` para forwarding +/// y debe ser no-falsificable. Invoke, ListEntes y power-mgmt se aceptan +/// anonymous — políticas por capacidad se aplican aguas abajo, no aquí. +fn requires_auth(req: &BusRequest) -> bool { + matches!(req, BusRequest::Announce { .. }) +} + +impl EnteGraph { + pub async fn on_bus_request( + &mut self, + peer: PeerCreds, + from: Option, + request: BusRequest, + outbound: mpsc::Sender, + reply: oneshot::Sender, + ) { + // ---- Auth: kernel-injected SO_PEERCRED vs identidad reclamada ---- + let from_authenticated = match from { + None => None, + Some(claimed) => { + let expected = self.incarnated.get(&claimed).and_then(|i| i.pid); + match expected { + Some(p) if p.as_raw() == peer.pid => Some(claimed), + Some(p) => { + warn!( + claimed = %claimed, expected_pid = p.as_raw(), + actual_pid = peer.pid, + "identity mismatch — rechazando request" + ); + let _ = reply.send(BusResponse::Error("identity mismatch".into())); + return; + } + None => { + warn!(?claimed, peer_pid = peer.pid, "Ente desconocido reclamando identidad"); + let _ = reply.send(BusResponse::Error("unknown ente claimed".into())); + return; + } + } + } + }; + if requires_auth(&request) && from_authenticated.is_none() { + let _ = reply.send(BusResponse::Error("auth required for this request".into())); + return; + } + + // ---- Dispatch ---- + match request { + BusRequest::Announce { capabilities } => { + let id = from_authenticated.expect("auth-required guarantees Some"); + let label = self.incarnated.get(&id).map(|i| i.card.label.clone()) + .unwrap_or_else(|| "anónimo".into()); + info!(%id, %label, ?capabilities, peer_pid = peer.pid, "Announce autenticado"); + self.bus_connections.insert(id, outbound); + let _ = reply.send(BusResponse::Ok); + } + BusRequest::ListEntes => { + let entes = self.incarnated.values() + .map(|i| EnteInfo { + id: i.card.id, + label: i.card.label.clone(), + provides: i.card.provides.iter().cloned().collect(), + pid: i.pid.map(|p| p.as_raw()), + }) + .collect(); + let _ = reply.send(BusResponse::Entes(entes)); + } + BusRequest::PowerOff { interactive } => { + info!(?from_authenticated, interactive, peer_pid = peer.pid, "PowerOff via bus"); + let _ = reply.send(BusResponse::Ok); + } + BusRequest::Reboot { interactive } => { + info!(?from_authenticated, interactive, "Reboot via bus"); + let _ = reply.send(BusResponse::Ok); + } + BusRequest::Suspend { interactive } => { + info!(?from_authenticated, interactive, "Suspend via bus"); + let _ = reply.send(BusResponse::Ok); + } + BusRequest::Hibernate { interactive } => { + info!(?from_authenticated, interactive, "Hibernate via bus"); + let _ = reply.send(BusResponse::Ok); + } + BusRequest::Invoke { cap, blob } => { + self.forward_invoke(from_authenticated, cap, blob, reply).await; + } + } + } + + /// Enruta un Invoke al proveedor real de la capacidad. Aloca un seq + /// server-side, registra el reply oneshot en `pending_invokes`, y empuja + /// el request por la conexión del proveedor. + async fn forward_invoke( + &mut self, + from: Option, + cap: Capability, + blob: Vec, + reply: oneshot::Sender, + ) { + let provider_id = match self.pick_invokable_provider(&cap) { + Some(id) => id, + None => { + let _ = reply.send(BusResponse::Error(format!("sin proveedor invokable para {cap:?}"))); + return; + } + }; + let outbound = match self.bus_connections.get(&provider_id) { + Some(o) => o.clone(), + None => { + let _ = reply.send(BusResponse::Error("proveedor no conectado al bus".into())); + return; + } + }; + let seq = self.alloc_invoke_seq(); + self.pending_invokes.insert(seq, reply); + debug!(?from, ?cap, ?provider_id, seq, blob_len = blob.len(), "forwardeando Invoke"); + + let msg = BusMessage { + from: None, + seq, + payload: BusPayload::Request(BusRequest::Invoke { cap, blob }), + }; + if outbound.send(msg).await.is_err() { + if let Some(orig) = self.pending_invokes.remove(&seq) { + let _ = orig.send(BusResponse::Error("conn del proveedor cerrada".into())); + } + } + } + + fn pick_invokable_provider(&self, cap: &Capability) -> Option { + // Sólo proveedores con conexión al bus pueden recibir forwards. + // El propio Ente #0 está en `providers` para varias caps pero no + // debe recibir forwards — se filtra implícitamente porque la Semilla + // no tiene conexión al bus. + self.providers.get(cap)? + .iter() + .find(|id| self.bus_connections.contains_key(id)) + .copied() + } + + pub(in crate::graph) fn alloc_invoke_seq(&mut self) -> u64 { + self.next_invoke_seq = self.next_invoke_seq.wrapping_add(1); + SERVER_SEQ_FLAG | self.next_invoke_seq + } + + pub async fn on_bus_response(&mut self, seq: u64, response: BusResponse) { + if let Some(orig) = self.pending_invokes.remove(&seq) { + let _ = orig.send(response); + } else { + warn!(seq, "Response sin pending invoke"); + } + } + + pub async fn on_bus_conn_closed(&mut self, ente_id: Option) { + if let Some(id) = ente_id { + self.bus_connections.remove(&id); + // No revocamos providers — la capacidad sigue declarada en su + // Card. Sólo perdimos el canal de invocación. + debug!(%id, "bus connection cerrada"); + } + } +} diff --git a/crates/ente-zero/src/graph/capabilities.rs b/crates/ente-zero/src/graph/capabilities.rs new file mode 100644 index 0000000..049ed1d --- /dev/null +++ b/crates/ente-zero/src/graph/capabilities.rs @@ -0,0 +1,33 @@ +//! Mediación de capabilities: emisión y revocación de tokens. +//! +//! El Init no fuerza políticas — sólo verifica que el proveedor existe y +//! emite tokens. Las políticas reales (quién puede pedir qué, rate limits, +//! audit) se aplican en capas superiores. + +use super::{EnteGraph, GrantedCapability}; +use crate::events::CapabilityGrant; +use ente_card::Capability; +use tokio::sync::oneshot; +use ulid::Ulid; + +impl EnteGraph { + pub async fn mediate_capability( + &mut self, + from: Ulid, + cap: Capability, + reply: oneshot::Sender, + ) { + let grant = match self.providers.get(&cap).and_then(|s| s.iter().next().copied()) { + None => CapabilityGrant::NoProvider, + Some(provider) => { + let token = self.next_token; + self.next_token += 1; + self.grants.insert(token, GrantedCapability { + cap: cap.clone(), provider, holder: from, + }); + CapabilityGrant::Granted { token } + } + }; + let _ = reply.send(grant); + } +} diff --git a/crates/ente-zero/src/graph/devices.rs b/crates/ente-zero/src/graph/devices.rs new file mode 100644 index 0000000..9a2a189 --- /dev/null +++ b/crates/ente-zero/src/graph/devices.rs @@ -0,0 +1,60 @@ +//! Device registry: mantiene el índice de dispositivos del kernel presentes, +//! traduce uevents en cambios de `Capability::Device { class }`. + +use super::EnteGraph; +use crate::events::GraphEvent; +use ente_card::{Capability, DeviceClass}; +use ente_kernel::{UAction, UEvent}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +impl EnteGraph { + pub async fn on_uevent(&mut self, evt: UEvent, _tx: &mpsc::Sender) { + let class = match &evt.device_class { + Some(c) => c.clone(), + None => return, // subsystems sin DeviceClass mapeada — ignoramos. + }; + match evt.action { + UAction::Add | UAction::Bind | UAction::Online => { + let was_first = self.devices_of_class(&class) == 0; + self.devices.insert(evt.devpath.clone(), evt.clone()); + if was_first { + // Primera instancia de la clase → la registramos como + // capacidad disponible. El "proveedor" virtual es el + // Ente #0 (kernel surface). + let cap = Capability::Device { class: class.clone() }; + self.providers.entry(cap).or_default().insert(self.seed.id); + info!(?class, devpath = %evt.devpath, "device capability disponible"); + } + } + UAction::Remove | UAction::Unbind | UAction::Offline => { + self.devices.remove(&evt.devpath); + if self.devices_of_class(&class) == 0 { + let cap = Capability::Device { class: class.clone() }; + if let Some(set) = self.providers.get_mut(&cap) { + set.remove(&self.seed.id); + } + let revoked: Vec = self.grants.iter() + .filter(|(_, g)| g.cap == cap) + .map(|(t, _)| *t) + .collect(); + for t in revoked { + self.grants.remove(&t); + } + warn!(?class, "última instancia removida — capacidad revocada"); + } + } + UAction::Change | UAction::Move => { + self.devices.insert(evt.devpath.clone(), evt); + debug!(?class, "device modified"); + } + UAction::Unknown => {} + } + } + + fn devices_of_class(&self, class: &DeviceClass) -> usize { + self.devices.values() + .filter(|e| e.device_class.as_ref() == Some(class)) + .count() + } +} diff --git a/crates/ente-zero/src/graph/lifecycle.rs b/crates/ente-zero/src/graph/lifecycle.rs new file mode 100644 index 0000000..867190e --- /dev/null +++ b/crates/ente-zero/src/graph/lifecycle.rs @@ -0,0 +1,151 @@ +//! Encarnación, muerte y supervisión. +//! +//! Aquí vive el flujo: Card → autorizar → soma::incarnate / wasm → registro +//! en el grafo → SIGCHLD → on_death → Restart/OneShot/Delegate. + +use super::{EnteGraph, Incarnated}; +use crate::events::{ExitStatus, GraphEvent}; +use ente_bus::{BusMessage, BusPayload, BusRequest}; +use ente_card::{Capability, EntityCard, Payload, Supervision}; +use tokio::sync::mpsc; +use tracing::{info, warn}; +use ulid::Ulid; + +impl EnteGraph { + /// Encarna las dependencias declaradas en la Semilla. Único punto donde + /// el Init "decide": después sólo reacciona. + pub async fn instantiate_seed_dependencies( + &mut self, + _tx: &mpsc::Sender, + ) -> anyhow::Result<()> { + let cards = std::mem::take(&mut self.pending_genesis); + if cards.is_empty() { + info!(seed = %self.seed.label, "semilla sin genesis cards"); + return Ok(()); + } + info!(seed = %self.seed.label, count = cards.len(), "instanciando genesis"); + let seed_id = self.seed.id; + for card in cards { + if let Err(e) = self.authorize_and_spawn(card, seed_id).await { + warn!(?e, "genesis card falló"); + } + } + Ok(()) + } + + /// Spawn solicitado por un Ente con `Capability::Spawn`. Verifica auth, + /// requires del grafo, y delega la encarnación al backend correspondiente + /// (`ente_soma` para procesos, `ente_wasm` para Wasm). + pub async fn authorize_and_spawn( + &mut self, + mut card: EntityCard, + requester: Ulid, + ) -> anyhow::Result<()> { + if !self.holder_has(requester, &Capability::Spawn) { + warn!(?requester, "spawn denied: lacks Capability::Spawn"); + return Ok(()); + } + if let Err(e) = card.validate() { + warn!(?e, label = %card.label, "card inválida, spawn rechazado"); + return Ok(()); + } + // Falla rápida sobre `requires` — mejor que daemons en bucle. + for req in &card.requires { + if !self.providers.contains_key(req) { + warn!(?req, label = %card.label, "requires no satisfecho"); + return Ok(()); + } + } + // Lineage por defecto = quien pidió el spawn. + if card.lineage.is_none() { + card.lineage = Some(requester); + } + + let pid = match &card.payload { + Payload::Virtual => None, + Payload::Native { .. } | Payload::Legacy { .. } => { + Some(ente_soma::incarnate(&card)?) + } + Payload::Wasm { module_sha256, entry } => { + // Wasm: hilo dedicado, sin PID. Su muerte se observa por + // estado del runtime, no por SIGCHLD. + let bytes = ente_cas::resolve(module_sha256) + .map_err(|e| anyhow::anyhow!("CAS resolve para {}: {e}", card.label))?; + ente_wasm::incarnate_wasm(&card, bytes, entry.clone())?; + None + } + }; + + if let Some(p) = pid { + self.by_pid.insert(p.as_raw(), card.id); + } + self.register_provider(&card); + if let Some(parent) = card.lineage { + self.children.entry(parent).or_default().push(card.id); + } + info!(label = %card.label, ?pid, lineage = ?card.lineage, "Ente encarnado"); + self.incarnated.insert(card.id, Incarnated { card, pid }); + Ok(()) + } + + pub async fn on_death( + &mut self, + id: Ulid, + status: ExitStatus, + _tx: &mpsc::Sender, + ) { + let Some(inc) = self.incarnated.remove(&id) else { return }; + if let Some(p) = inc.pid { + self.by_pid.remove(&p.as_raw()); + } + self.unregister_provider(&inc.card); + if let Some(parent) = inc.card.lineage { + if let Some(siblings) = self.children.get_mut(&parent) { + siblings.retain(|c| c != &id); + } + } + info!(label = %inc.card.label, ?status, "Ente disuelto"); + + match inc.card.supervision.clone() { + Supervision::Restart { initial, max: _ } => { + // Backoff exponencial: TODO real con timer del runtime. + tokio::time::sleep(initial).await; + let new_card = EntityCard { id: Ulid::new(), ..inc.card }; + if let Err(e) = self.authorize_and_spawn(new_card, self.seed.id).await { + warn!(?e, "restart falló"); + } + } + Supervision::OneShot => {} + Supervision::Delegate => { + self.notify_lineage_of_death(&inc, &status); + } + } + } + + /// Fire-and-forget: si el parent tiene conexión al bus, le forwardeamos + /// un Invoke con la muerte del hijo. Sin retry, sin backpressure. + fn notify_lineage_of_death(&mut self, inc: &Incarnated, status: &ExitStatus) { + let Some(parent) = inc.card.lineage else { return }; + info!( + child = %inc.card.id, parent = %parent, label = %inc.card.label, + ?status, + "Supervision::Delegate — muerte notificada al lineage" + ); + if let Some(out) = self.bus_connections.get(&parent).cloned() { + let blob = format!("{}:{:?}", inc.card.id, status); + let seq = self.alloc_invoke_seq(); + let msg = BusMessage { + from: None, + seq, + payload: BusPayload::Request(BusRequest::Invoke { + cap: Capability::Endpoint { + interface: ente_card::InterfaceId([0xde; 16]), + version: 1, + }, + blob: blob.into_bytes(), + }), + }; + let _ = out.try_send(msg); + } + } +} diff --git a/crates/ente-zero/src/graph/mod.rs b/crates/ente-zero/src/graph/mod.rs new file mode 100644 index 0000000..016a640 --- /dev/null +++ b/crates/ente-zero/src/graph/mod.rs @@ -0,0 +1,158 @@ +//! `EnteGraph`: estado del fractal vivo en PID 1. +//! +//! Diseño: +//! - Submódulos por concern: lifecycle, topology, shutdown, bus_mediator, +//! devices, capabilities. Cada uno extiende `impl EnteGraph` con métodos +//! relacionados. +//! - Estado plano (no substructs todavía) — la separación es por +//! comportamiento, no por compartimentación de datos. +//! - Toda mutación pasa por el bucle primordial vía `GraphEvent`. Los +//! submódulos se llaman desde `main.rs::primordial_loop`. + +mod bus_mediator; +mod capabilities; +mod devices; +mod lifecycle; +mod shutdown; +mod topology; + +use ente_bus::{BusMessage, BusResponse}; +use ente_card::{Capability, EntityCard}; +use nix::unistd::Pid; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use tokio::sync::{mpsc, oneshot}; +use ulid::Ulid; + +pub use shutdown::SHUTDOWN_GRACE; + +/// Bit alto encendido en `seq` para invokes server-iniciados — evita choque +/// con secuencias allocadas por clientes. +pub(in crate::graph) const SERVER_SEQ_FLAG: u64 = 1u64 << 63; + +pub struct EnteGraph { + pub(in crate::graph) seed: EntityCard, + /// Entes encarnados como proceso o nodo virtual. id↔pid bidireccional. + pub(in crate::graph) incarnated: HashMap, + pub(in crate::graph) by_pid: HashMap, + /// Quién provee qué capacidad. Resuelve `requires` y `pick_invokable`. + pub(in crate::graph) providers: BTreeMap>, + /// Tokens de capability emitidos. Revocables al morir el proveedor. + pub(in crate::graph) next_token: u64, + pub(in crate::graph) grants: HashMap, + /// Dispositivos del kernel presentes (devpath → última UEvent). + pub(in crate::graph) devices: HashMap, + /// Cards genesis pendientes de instanciar (extraídas de la Semilla). + pub(in crate::graph) pending_genesis: Vec, + /// Hijos directos por lineage. parent → [child, ...]. + pub(in crate::graph) children: HashMap>, + /// Conexiones del bus indexadas por la identidad anunciada y verificada + /// con SO_PEERCRED. El value es el extremo de escritura del writer task. + pub(in crate::graph) bus_connections: HashMap>, + /// Invokes forwardeados pendientes de respuesta del proveedor. + pub(in crate::graph) pending_invokes: HashMap>, + pub(in crate::graph) next_invoke_seq: u64, +} + +pub(in crate::graph) struct Incarnated { + pub card: EntityCard, + pub pid: Option, +} + +pub(in crate::graph) struct GrantedCapability { + pub cap: Capability, + pub provider: Ulid, + pub holder: Ulid, +} + +impl EnteGraph { + pub fn new(mut seed: EntityCard) -> Self { + // Extraemos genesis antes de almacenar la Semilla — evita duplicación + // en `incarnated[seed.id]`. + let pending_genesis = std::mem::take(&mut seed.genesis); + let mut g = Self { + seed: seed.clone(), + incarnated: HashMap::new(), + by_pid: HashMap::new(), + providers: BTreeMap::new(), + next_token: 1, + grants: HashMap::new(), + devices: HashMap::new(), + pending_genesis, + children: HashMap::new(), + bus_connections: HashMap::new(), + pending_invokes: HashMap::new(), + next_invoke_seq: 0, + }; + // El Ente #0 se inscribe a sí mismo como proveedor de las capacidades + // que su Card declara — sólo así los hijos pueden requerirlas. + g.register_provider(&seed); + g.incarnated.insert(seed.id, Incarnated { card: seed, pid: None }); + g + } + + pub fn lookup_pid(&self, pid: Pid) -> Option { + self.by_pid.get(&pid.as_raw()).copied() + } + + /// Acceso read-only a la Card de un Ente vivo. Usado por el cerebro + /// para hidratar `SubjectInfo` sin clonar todo el mapa. + pub fn peek_card(&self, id: &Ulid) -> Option<&EntityCard> { + self.incarnated.get(id).map(|i| &i.card) + } + + /// Identidad de la Semilla. Usado como `requester` para spawns generados + /// por reglas auto-cristalizadas (única identidad con Capability::Spawn). + pub fn seed_id(&self) -> Ulid { + self.seed.id + } + + /// Captura el estado live como snapshot serializable. Excluye la Semilla + /// (será re-sintetizada al restore con su seed_id preservado). + pub fn snapshot(&self) -> ente_snapshot::FractalSnapshot { + let entes: Vec = self.incarnated.iter() + .filter(|(id, _)| **id != self.seed.id) + .map(|(_, inc)| inc.card.clone()) + .collect(); + ente_snapshot::FractalSnapshot { + version: ente_snapshot::SNAPSHOT_VERSION, + timestamp_ms: ente_snapshot::now_ms(), + seed_id: self.seed.id, + seed_label: self.seed.label.clone(), + entes, + } + } + + pub(in crate::graph) fn register_provider(&mut self, card: &EntityCard) { + for cap in &card.provides { + self.providers.entry(cap.clone()).or_default().insert(card.id); + } + } + + pub(in crate::graph) fn unregister_provider(&mut self, card: &EntityCard) { + for cap in &card.provides { + if let Some(set) = self.providers.get_mut(cap) { + set.remove(&card.id); + } + } + // Revocar grants emitidos por el Ente fallecido. + let revoked: Vec = self.grants.iter() + .filter(|(_, g)| g.provider == card.id) + .map(|(t, _)| *t) + .collect(); + for t in revoked { + self.grants.remove(&t); + } + } + + /// El Ente #0 (semilla) tiene todas sus capacidades declaradas. Otros + /// las tienen si su Card las declara o si poseen un grant vivo. + pub(in crate::graph) fn holder_has(&self, holder: Ulid, cap: &Capability) -> bool { + if holder == self.seed.id { + return self.seed.provides.contains(cap); + } + if let Some(inc) = self.incarnated.get(&holder) { + if inc.card.provides.contains(cap) { return true; } + } + self.grants.values().any(|g| g.holder == holder && &g.cap == cap) + } +} diff --git a/crates/ente-zero/src/graph/shutdown.rs b/crates/ente-zero/src/graph/shutdown.rs new file mode 100644 index 0000000..ac26d0b --- /dev/null +++ b/crates/ente-zero/src/graph/shutdown.rs @@ -0,0 +1,100 @@ +//! Cascade shutdown: SIGTERM en orden topológico (hojas primero), grace +//! period, SIGKILL para stragglers, reap final. + +use super::EnteGraph; +use nix::errno::Errno; +use nix::sys::signal::{kill, Signal}; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use nix::unistd::Pid; +use std::time::{Duration, Instant}; +use tracing::{debug, info, warn}; + +/// Tiempo que damos a los Entes tras SIGTERM antes de escalar a SIGKILL. +pub const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); + +impl EnteGraph { + pub async fn cascade_shutdown(&mut self) { + let order = self.topo_order(); + let pids: Vec = order.iter() + .filter_map(|id| self.incarnated.get(id).and_then(|i| i.pid)) + .collect(); + + if pids.is_empty() { + info!("cascade shutdown: ningún Ente encarnado, salida limpia"); + return; + } + + info!( + count = pids.len(), grace_ms = SHUTDOWN_GRACE.as_millis() as u64, + "SIGTERM cascade (topológico, hojas primero)" + ); + for pid in &pids { + match kill(*pid, Signal::SIGTERM) { + Ok(()) => {} + Err(Errno::ESRCH) => {} // ya muerto, lo cosecharemos abajo + Err(e) => warn!(?pid, ?e, "kill SIGTERM falló"), + } + } + + let deadline = Instant::now() + SHUTDOWN_GRACE; + while Instant::now() < deadline { + if !self.incarnated.values().any(|i| i.pid.is_some()) { + break; + } + match waitpid(None, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(pid, code)) => { + self.reap_during_shutdown(pid); + debug!(?pid, code, "reaped (exited)"); + } + Ok(WaitStatus::Signaled(pid, sig, _)) => { + self.reap_during_shutdown(pid); + debug!(?pid, ?sig, "reaped (signaled)"); + } + Ok(WaitStatus::StillAlive) | Err(Errno::EINTR) => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(Errno::ECHILD) => return, + Ok(_) => {} + Err(e) => { + warn!(?e, "waitpid fallo en shutdown grace"); + break; + } + } + } + + let stragglers: Vec = self.incarnated.values() + .filter_map(|i| i.pid) + .collect(); + + if stragglers.is_empty() { + info!("cascade shutdown completo (todos los Entes terminaron en gracia)"); + return; + } + + warn!(count = stragglers.len(), "stragglers post-SIGTERM, escalando a SIGKILL"); + for pid in &stragglers { + let _ = kill(*pid, Signal::SIGKILL); + } + loop { + match waitpid(None, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(pid, _)) | Ok(WaitStatus::Signaled(pid, _, _)) => { + self.reap_during_shutdown(pid); + } + Ok(WaitStatus::StillAlive) => { + std::thread::sleep(Duration::from_millis(20)); + } + Err(Errno::ECHILD) => break, + _ => break, + } + if !self.incarnated.values().any(|i| i.pid.is_some()) { break; } + } + info!("cascade shutdown completo (con SIGKILL)"); + } + + fn reap_during_shutdown(&mut self, pid: Pid) { + let Some(id) = self.by_pid.remove(&pid.as_raw()) else { return }; + if let Some(inc) = self.incarnated.remove(&id) { + self.unregister_provider(&inc.card); + } + } +} diff --git a/crates/ente-zero/src/graph/topology.rs b/crates/ente-zero/src/graph/topology.rs new file mode 100644 index 0000000..879e66c --- /dev/null +++ b/crates/ente-zero/src/graph/topology.rs @@ -0,0 +1,35 @@ +//! Topología del fractal: índice de hijos por lineage y orden topológico +//! para shutdown. + +use super::EnteGraph; +use std::collections::BTreeSet; +use ulid::Ulid; + +impl EnteGraph { + /// DFS post-order desde la Semilla. Hojas primero, raíz al final. + /// Garantiza que SIGTERM va a un padre sólo cuando sus hijos ya recibieron + /// la señal (evita orfandad transitoria que confunda Restart supervisors). + pub(in crate::graph) fn topo_order(&self) -> Vec { + let mut visited = BTreeSet::new(); + let mut order = Vec::new(); + self.dfs_post(self.seed.id, &mut visited, &mut order); + // Entes encarnados sin lineage hacia el seed (no debería pasar pero + // protege contra grafos huérfanos): añadirlos al final. + for id in self.incarnated.keys() { + if !visited.contains(id) { + self.dfs_post(*id, &mut visited, &mut order); + } + } + order + } + + fn dfs_post(&self, node: Ulid, visited: &mut BTreeSet, order: &mut Vec) { + if !visited.insert(node) { return; } + if let Some(children) = self.children.get(&node) { + for c in children.clone() { + self.dfs_post(c, visited, order); + } + } + order.push(node); + } +} diff --git a/crates/ente-zero/src/main.rs b/crates/ente-zero/src/main.rs new file mode 100644 index 0000000..5b19cb6 --- /dev/null +++ b/crates/ente-zero/src/main.rs @@ -0,0 +1,380 @@ +//! Ente #0 — el primer Ente. PID 1 del fractal. +//! +//! Reglas no negociables: +//! 1. NUNCA lógica de servicio aquí. Sólo: leer Semilla, cosechar zombis, +//! mediar capacidades, propagar eventos. +//! 2. Single-threaded. Cualquier paralelismo se delega a Entes worker. +//! Un panic en un thread de PID 1 = kernel panic. +//! 3. Errores de hijos son *eventos* en `graph_tx`, no `Result` propagado. +//! +//! Este archivo es sólo wireup. La lógica vive en: +//! - `seed` : construcción/restauración de la Tarjeta Semilla +//! - `bus` : listener Unix + auth via SO_PEERCRED +//! - `graph::*` : estado del fractal (lifecycle, topology, shutdown, +//! bus_mediator, devices, capabilities) +//! - `events` : tipos de eventos del bucle primordial +//! - crates externos del workspace para CAS, soma, wasm, snapshot, kernel. + +mod brain_glue; +mod bus; +mod events; +mod graph; +mod seed; + +use anyhow::Context; +use ente_brain::{BrainState, IntrospectServer}; +use ente_kernel::{become_child_subreaper, bootstrap_kernel_surface, spawn_sigchld_stream, spawn_uevent_stream}; +use events::{ExitStatus, GraphEvent, ShutdownReason}; +use graph::EnteGraph; +use nix::errno::Errno; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use nix::unistd::{getpid, Pid}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +struct CliArgs { + checkpoint: Option, + restore: Option, + rules: Option, + rules_out: Option, + metrics_addr: Option, +} + +fn parse_args() -> CliArgs { + let mut args = std::env::args().skip(1); + let mut checkpoint = None; + let mut restore = None; + let mut rules = None; + let mut rules_out = None; + let mut metrics_addr = None; + while let Some(a) = args.next() { + match a.as_str() { + "--checkpoint" => checkpoint = args.next().map(PathBuf::from), + "--restore" => restore = args.next().map(PathBuf::from), + "--rules" => rules = args.next().map(PathBuf::from), + "--rules-out" => rules_out = args.next().map(PathBuf::from), + "--metrics-addr" => metrics_addr = args.next(), + other => warn!(arg = %other, "argumento desconocido, ignorado"), + } + } + CliArgs { checkpoint, restore, rules, rules_out, metrics_addr } +} + +fn main() -> anyhow::Result<()> { + init_tracing(); + let cli = parse_args(); + let pid = getpid(); + let dev_mode = pid != Pid::from_raw(1); + + if dev_mode { + warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta"); + } else { + info!("ente-zero despierta como PID 1"); + bootstrap_kernel_surface().context("bootstrap kernel surface")?; + become_child_subreaper().context("PR_SET_CHILD_SUBREAPER")?; + } + + let card = seed::load(dev_mode, cli.restore.as_deref())?; + + // current_thread runtime: ver doctrina al inicio del módulo. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build()?; + + rt.block_on(primordial_loop(card, dev_mode, cli.checkpoint, cli.rules, cli.rules_out, cli.metrics_addr)) +} + +async fn primordial_loop( + seed_card: ente_card::EntityCard, + dev_mode: bool, + checkpoint_path: Option, + rules_path: Option, + rules_out: Option, + metrics_addr: Option, +) -> anyhow::Result<()> { + info!(seed_id = %seed_card.id, label = %seed_card.label, "Ente #0 entra al bucle primordial"); + + let (graph_tx, mut graph_rx) = mpsc::channel::(64); + let mut sigchld = spawn_sigchld_stream()?; + // Uevents puede fallar en dev (sin CAP_NET_ADMIN). Degradamos a un + // canal nunca-listo en lugar de abortar el bucle primordial. + let mut uevents = match spawn_uevent_stream() { + Ok(rx) => rx, + Err(e) => { + warn!(?e, "uevents deshabilitados (probablemente falta CAP_NET_ADMIN)"); + let (_keep_tx, rx) = mpsc::channel::(1); + std::mem::forget(_keep_tx); + rx + } + }; + + // Bus interno: listener antes de spawn de hijos para que su Announce + // tenga adónde llegar. Su path se inyecta en ENTE_BUS_SOCK por soma. + let bus_sock = bus::default_socket_path(); + let bus_path = bus::spawn_bus(bus_sock, graph_tx.clone())?; + ente_soma::set_bus_sock(bus_path.to_string_lossy().into_owned()); + + let mut graph = EnteGraph::new(seed_card); + graph.instantiate_seed_dependencies(&graph_tx).await?; + + // Cerebro: BrainState compartido + servidor de introspección. + // Window de 1024 eventos — suficiente para correlaciones interesantes + // sin gastar memoria de PID 1. En dev bajamos el umbral de cristalización + // para que el demo (pocos eventos) produzca cristales observables. + let mut brain = if dev_mode { + // Umbrales relajados para que el demo (pocos eventos) produzca + // cristales observables. Con P(b|a) normalizada a [0,1], los + // valores típicos en muestras pequeñas son 0.2-0.5. + BrainState::with_params(1024, ente_brain::CrystallizationParams { + min_support: 2, + min_conditional_prob: 0.3, + min_pmi: 1.0, + }) + } else { + BrainState::new(1024) + }; + if let Some(out_path) = rules_out { + brain = brain.with_rules_out(out_path); + } + + // Carga inicial de reglas vía KCL o JSON, si --rules path proporcionado. + if let Some(path) = &rules_path { + match ente_brain::load_rules_file(path) { + Ok(rules) => { + let mut engine = brain.engine.write().await; + for r in rules { + engine.insert(r); + } + info!(count = engine.len(), path = %path.display(), "reglas cargadas"); + } + Err(e) => warn!(?e, path = %path.display(), "carga de reglas falló"), + } + } + + // Endpoint Prometheus opcional. En dev por defecto en 127.0.0.1:9911 si + // el flag no se pasó. + let metrics_addr = metrics_addr.or_else(|| { + if dev_mode { Some("127.0.0.1:9911".to_string()) } else { None } + }); + if let Some(addr_s) = metrics_addr { + match addr_s.parse::() { + Ok(addr) => { + let s = brain.clone(); + tokio::spawn(async move { + if let Err(e) = ente_brain::serve_metrics(s, addr).await { + warn!(?e, "metrics server cayó"); + } + }); + } + Err(e) => warn!(?e, addr = %addr_s, "metrics-addr inválido"), + } + } + spawn_brain_introspect(brain.clone()); + let brain_sink = brain_glue::GraphSink { + graph_tx: graph_tx.clone(), + // Spawns auto-disparados desde reglas usan la identidad de la Semilla + // (único Ente con Capability::Spawn por construcción). + requester: graph.seed_id(), + }; + + // Demo automático del forwarding (sólo dev, sólo si el binario existe). + if dev_mode && std::path::Path::new("target/debug/ente-echo").exists() { + spawn_echo_smoke_test(bus_path.clone()); + } + + // En dev mode no tenemos hijos por defecto y el bucle se quedaría inerte. + let dev_exit = if dev_mode { + Some(tokio::time::sleep(Duration::from_secs(4))) + } else { + None + }; + tokio::pin!(dev_exit); + + loop { + tokio::select! { + biased; + + Some(_) = sigchld.recv() => { + reap_until_empty(&mut graph, &graph_tx).await; + } + + Some(uevt) = uevents.recv() => { + graph.on_uevent(uevt, &graph_tx).await; + } + + Some(evt) = graph_rx.recv() => { + // Cerebro observa antes que el grafo mute. Snapshot del + // SubjectInfo se hace contra el estado pre-mutación. + feed_brain(&brain, &brain_sink, &graph, &evt).await; + if dispatch_graph_event(&mut graph, evt, &graph_tx, &checkpoint_path).await { + return Ok(()); + } + } + + _ = async { dev_exit.as_mut().as_pin_mut().unwrap().await }, if dev_mode => { + info!("dev mode: timer expirado, cerrando bucle primordial"); + let _ = graph_tx.send(GraphEvent::Shutdown { + reason: ShutdownReason::SeedRequested, + }).await; + } + } + } +} + +/// Devuelve `true` si el bucle primordial debe terminar. +async fn dispatch_graph_event( + graph: &mut EnteGraph, + evt: GraphEvent, + tx: &mpsc::Sender, + checkpoint: &Option, +) -> bool { + match evt { + GraphEvent::EnteDied { id, status } => { + graph.on_death(id, status, tx).await; + } + GraphEvent::CapabilityRequested { from, cap, reply } => { + graph.mediate_capability(from, cap, reply).await; + } + GraphEvent::SpawnRequest { card, requester } => { + if let Err(e) = graph.authorize_and_spawn(card, requester).await { + warn!(?e, "spawn request error"); + } + } + GraphEvent::BusRequest { peer, from, request, outbound, reply } => { + graph.on_bus_request(peer, from, request, outbound, reply).await; + } + GraphEvent::BusResponse { seq, response } => { + graph.on_bus_response(seq, response).await; + } + GraphEvent::BusConnClosed { ente_id } => { + graph.on_bus_conn_closed(ente_id).await; + } + GraphEvent::Shutdown { reason } => { + warn!(?reason, "shutdown del fractal"); + if let Some(path) = checkpoint.as_ref() { + let snap = graph.snapshot(); + match snap.write(path) { + Ok(()) => info!(path = %path.display(), entes = snap.entes.len(), "snapshot persistido"), + Err(e) => warn!(?e, "snapshot write falló"), + } + } + graph.cascade_shutdown().await; + return true; + } + } + false +} + +async fn reap_until_empty(graph: &mut EnteGraph, tx: &mpsc::Sender) { + loop { + match waitpid(None, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::StillAlive) => return, + Ok(WaitStatus::Exited(pid, code)) => { + emit_death(graph, tx, pid, ExitStatus::Exit(code)).await; + } + Ok(WaitStatus::Signaled(pid, sig, _core)) => { + emit_death(graph, tx, pid, ExitStatus::Killed(sig)).await; + } + Ok(_) => continue, // Stopped/Continued — irrelevantes + Err(Errno::ECHILD) => return, + Err(e) => { + error!(?e, "waitpid fallo no recuperable en bucle de reaping"); + return; + } + } + } +} + +async fn emit_death( + graph: &EnteGraph, + tx: &mpsc::Sender, + pid: Pid, + status: ExitStatus, +) { + let id = match graph.lookup_pid(pid) { + Some(id) => id, + None => { + // Proceso adoptado (subreaper): no está en nuestro grafo. + info!(?pid, ?status, "huérfano cosechado (no en grafo)"); + return; + } + }; + let _ = tx.send(GraphEvent::EnteDied { id, status }).await; +} + +fn spawn_echo_smoke_test(bus_path: PathBuf) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + match ente_bus::BusClient::connect(&bus_path).await { + Ok(mut client) => { + let req = ente_bus::BusRequest::Invoke { + cap: ente_echo::echo_capability(), + blob: b"hola fractal forwardeado".to_vec(), + }; + match client.call(req).await { + Ok(ente_bus::BusResponse::Invoked { result }) => { + info!(echo = %String::from_utf8_lossy(&result), "Invoke ECHO round-trip OK"); + } + Ok(other) => warn!(?other, "Invoke ECHO respuesta inesperada"), + Err(e) => warn!(?e, "Invoke ECHO falló"), + } + } + Err(e) => warn!(?e, "no se pudo conectar al bus para test"), + } + }); +} + +fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ente_zero=debug,info")); + fmt().with_env_filter(filter).with_target(true).init(); +} + +fn brain_introspect_path() -> PathBuf { + if let Ok(p) = std::env::var("ENTE_BRAIN_SOCK") { + return p.into(); + } + let runtime = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into())); + format!("{runtime}/ente-brain.sock").into() +} + +fn spawn_brain_introspect(state: BrainState) { + let path = brain_introspect_path(); + tokio::spawn(async move { + let server = IntrospectServer::new(state); + if let Err(e) = server.serve(&path).await { + warn!(?e, "introspect server cayó"); + } + }); +} + +/// Registra el evento en el observer y dispatcha cualquier regla matched. +/// Para reglas Sequence: pasamos los últimos N eventos del observer como +/// history al engine. +async fn feed_brain( + brain: &BrainState, + sink: &brain_glue::GraphSink, + graph: &EnteGraph, + evt: &GraphEvent, +) { + let Some((kind, subj)) = brain_glue::graph_event_to_brain(evt, graph) else { return }; + let history: Vec = { + let mut obs = brain.observer.write().await; + obs.record(kind.clone()); + // Snapshot de los últimos 16 eventos — suficiente para cualquier + // Sequence pattern razonable. Clone hace una sola alocación. + obs.recent(16).cloned().collect() + }; + let rules = { + let engine = brain.engine.read().await; + engine.dispatch(&kind, &subj, &history) + }; + if !rules.is_empty() { + ente_brain::dispatch_actions(&rules, sink).await; + } +} diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs new file mode 100644 index 0000000..bf8acb4 --- /dev/null +++ b/crates/ente-zero/src/seed.rs @@ -0,0 +1,220 @@ +//! Construcción de la Tarjeta Semilla. +//! +//! Tres caminos: +//! 1. `--restore `: leer `FractalSnapshot` y reconstruir Semilla +//! con seed_id preservado + entes anteriores como genesis. +//! 2. `seed.card` en disco: deserialize directo (prod o dev). +//! 3. Fallback dev: sintetizar Semilla + 6 genesis Entes que ejercitan +//! todas las capacidades del fractal. + +use anyhow::Context; +use ente_card::{ + Capability, CardError, CgroupSpec, EntityCard, NamespaceSet, Payload, + ResourceLimits, SomaSpec, Supervision, CARD_SCHEMA_VERSION, +}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tracing::{info, warn}; +use ulid::Ulid; + +const SEED_PATH_PROD: &str = "/ente/seed.card"; +const SEED_PATH_DEV: &str = "seed.card"; + +pub fn load(dev_mode: bool, restore: Option<&Path>) -> anyhow::Result { + let card = if let Some(path) = restore { + load_from_snapshot(path)? + } else { + load_or_synthesize(dev_mode)? + }; + card.validate() + .map_err(|e: CardError| anyhow::anyhow!("semilla inválida: {e}"))?; + Ok(card) +} + +fn load_from_snapshot(path: &Path) -> anyhow::Result { + let snap = ente_snapshot::FractalSnapshot::read(path) + .with_context(|| format!("read snapshot {}", path.display()))?; + info!( + path = %path.display(), + seed_id = %snap.seed_id, + entes = snap.entes.len(), + timestamp_ms = snap.timestamp_ms, + "snapshot cargado, restaurando fractal" + ); + // Reconstruimos la Semilla con su Ulid original. Las Cards persistidas + // van a `genesis` con sus Ulids preservados — son las mismas identidades + // que vivieron antes del checkpoint. + let mut provides = BTreeSet::new(); + provides.insert(Capability::Spawn); + provides.insert(Capability::Journal); + Ok(EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: snap.seed_id, + lineage: None, + label: snap.seed_label, + provides, + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis: snap.entes, + }) +} + +fn load_or_synthesize(dev_mode: bool) -> anyhow::Result { + // Buscamos primero `.k` (KCL canónico, validado por su schema), luego + // `.json` para compatibilidad. La puerta genética se cruza vía + // `ente_brain::load_card_file` que pasa por `validate()` extendido. + let candidates: &[&str] = if dev_mode { + &["seed.card.k", SEED_PATH_DEV] + } else { + &["/ente/seed.card.k", SEED_PATH_PROD] + }; + for cand in candidates { + let path = PathBuf::from(cand); + if !path.exists() { continue; } + let card = ente_brain::load_card_file(&path) + .with_context(|| format!("load {}", path.display()))?; + info!(path = %path.display(), "Tarjeta Semilla cargada y validada"); + return Ok(card); + } + if dev_mode { + info!("sin seed.card — sintetizando semilla mínima (dev)"); + return Ok(synthesize_dev_seed()); + } + anyhow::bail!("seed.card no encontrada en /ente/seed.card[.k]") +} + +fn synthesize_dev_seed() -> EntityCard { + let mut provides = BTreeSet::new(); + provides.insert(Capability::Spawn); + provides.insert(Capability::Journal); + + // Pre-registramos el módulo Wasm demo en el CAS y obtenemos su SHA real. + // Si el CAS no es escribible (raro en dev) caemos a un SHA cero — la + // resolución fallará y el Wasm no encarnará, pero el resto queda intacto. + let demo_wasm_sha = match ente_wasm::demo_module_bytes() + .and_then(|b| ente_cas::store(&b)) + { + Ok(sha) => sha, + Err(e) => { + warn!(?e, "CAS no disponible — demo-wasm no encarnará"); + [0u8; 32] + } + }; + + let mut genesis = Vec::new(); + genesis.push(make_card("demo-sleep", Payload::Native { + exec: "/bin/sleep".into(), argv: vec!["1".into()], envp: vec![], + }, Supervision::OneShot)); + + genesis.push(make_card("demo-persist", Payload::Native { + exec: "/bin/sleep".into(), argv: vec!["60".into()], envp: vec![], + }, restart_supervision())); + + // Card namespaced: padre escribe uid_map, hijo cat /proc/self/uid_map. + let mut ns_card = make_card("demo-userns", Payload::Native { + exec: "/bin/cat".into(), + argv: vec!["/proc/self/uid_map".into()], + envp: vec![], + }, Supervision::OneShot); + ns_card.soma = SomaSpec { + namespaces: NamespaceSet { user: true, ..Default::default() }, + ..Default::default() + }; + genesis.push(ns_card); + + genesis.push(make_card("demo-wasm", Payload::Wasm { + module_sha256: demo_wasm_sha, + entry: "_start".into(), + }, Supervision::OneShot)); + + if let Some(card) = optional_native_card( + "demo-echo", "target/debug/ente-echo", + [ente_echo::echo_capability()].into_iter().collect(), + restart_supervision(), + ) { + genesis.push(card); + } + + if let Some(card) = optional_native_card( + "compat-logind", "target/debug/ente-logind-compat", + [Capability::LegacyLogind].into_iter().collect(), + restart_supervision(), + ) { + genesis.push(card); + } + + EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: "ente-zero-dev".into(), + provides, + requires: BTreeSet::new(), + soma: SomaSpec { + namespaces: NamespaceSet::default(), + rlimits: ResourceLimits::default(), + cgroup: CgroupSpec { + path: "ente.slice/zero".into(), + cpu_weight: None, + io_weight: None, + }, + cpu_affinity: None, + }, + payload: Payload::Virtual, + supervision: Supervision::OneShot, + genesis, + } +} + +fn make_card(label: &str, payload: Payload, supervision: Supervision) -> EntityCard { + EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: label.into(), + provides: BTreeSet::new(), + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload, + supervision, + genesis: vec![], + } +} + +fn optional_native_card( + label: &str, + bin_path: &str, + provides: BTreeSet, + supervision: Supervision, +) -> Option { + let path = Path::new(bin_path); + if !path.exists() { + return None; + } + Some(EntityCard { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: label.into(), + provides, + requires: BTreeSet::new(), + soma: SomaSpec::default(), + payload: Payload::Native { + exec: path.to_string_lossy().into_owned(), + argv: vec![], + envp: vec![], + }, + supervision, + genesis: vec![], + }) +} + +fn restart_supervision() -> Supervision { + Supervision::Restart { + initial: Duration::from_millis(100), + max: Duration::from_secs(30), + } +}