refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
+37
View File
@@ -0,0 +1,37 @@
# protocol/ — Contratos canónicos + routing
**Propósito.** Núcleo de intercomunicación del fractal: tipos canónicos
(`Card`, capabilities, soma), handshake módulo↔Init, broker que empareja
flows por tipo, y wire types de red. No corre lógica de dominio.
## Crates
| crate | tipo | rol |
| ---------------------- | ---- | -------------------------------------------------------- |
| `brahman-card` | lib | `Card { soma, payload, flow, permissions, supervision }` |
| `brahman-card-wit` | lib | Extracción WIT desde componentes WASM |
| `brahman-cards` | lib | Templates Nickel + helpers consumer/producer/broker |
| `brahman-handshake` | lib | Init↔módulo: Hello, Ping, ListSessions, ListMatches |
| `brahman-broker` | lib | Service locator: flow.input ↔ flow.output por tipo |
| `brahman-admin` | lib | Socket admin separado: snapshots de sesiones+matches |
| `brahman-sidecar` | lib | `spawn(card)` para que apps se presenten al Init |
| `brahman-net` | lib | Malla libp2p opcional (TCP+noise+yamux+kad) |
| `ente-card` | lib | Alias legacy — re-export de `brahman-card` |
## Dependencias
- Intra: handshake → card + broker; sidecar → card; net → handshake.
- Cross: consumido por todos los `modules/*` y `apps/*`.
- No depende de `init/` ni de `runtime/` (es la base).
## Invariantes
- Serialización: `postcard` length-prefixed sobre Unix SOCK_STREAM, JSON
sobre HTTP (gateway). Schemas reproducibles vía Nickel.
- Identidad: `peer_id` libp2p + firma Ed25519 anclada al peer.
- Sesiones: el handshake mantiene una sesión por módulo encarnado.
## Estado
Base estable (6,260 LOC con tests). 19 TODOs concentrados en handshake
(trust remoto fase 3) y broker. Ver `docs/changelog/protocol.md`.
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "brahman-admin"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — admin API: snapshot del estado del broker (sesiones + matches) por Unix socket, formato JSON."
[dependencies]
brahman-broker = { path = "../brahman-broker" }
brahman-card = { path = "../brahman-card" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
ulid = { workspace = true }
anyhow = { workspace = true }
[[example]]
name = "brahman-status"
path = "examples/brahman-status.rs"
@@ -0,0 +1,98 @@
//! `brahman-status` — CLI para inspeccionar el estado del Init.
//!
//! Conecta al socket admin (default `$XDG_RUNTIME_DIR/brahman-admin.sock`,
//! override con `$BRAHMAN_ADMIN_SOCKET`), recibe el snapshot, y lo imprime.
use brahman_admin::{client, transport};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let path = transport::default_socket_path();
let snap = client::query(&path).await?;
println!(
"Init: server={} protocol={} attached={}",
snap.server_version, snap.protocol_version, snap.init_attached
);
if let Some(ctx) = &snap.current_context {
println!("Context: {}", ctx);
}
println!();
println!("Sessions ({}):", snap.sessions.len());
if snap.sessions.is_empty() {
println!(" (ninguna)");
} else {
for s in &snap.sessions {
let conscious_marker = if s.wit.is_some() { " 🧠" } else { "" };
let kind_marker = match s.kind {
brahman_card::CardKind::Ente => "ente",
brahman_card::CardKind::Data => "data",
};
println!(
" [{}] {} {}{} lifecycle={:?} priority={:?}",
kind_marker, s.session, s.label, conscious_marker, s.lifecycle, s.priority
);
if let Some(sock) = &s.service_socket {
println!(" socket: {}", sock.display());
}
for r in &s.references {
println!(
" ref {:?}{} ({})",
r.kind, r.target_label, r.target_id
);
}
if let Some(data) = &s.data {
if !data.summary.is_empty() {
println!(" summary: {}", data.summary);
}
if data.member_count > 0 {
println!(
" members: {} (dispersion={:.2})",
data.member_count, data.dispersion
);
}
if !data.keywords.is_empty() {
println!(" keywords: {}", data.keywords.join(", "));
}
if !data.presentation_hint.is_empty() {
println!(" lens hint: {}", data.presentation_hint);
}
}
if let Some(wit) = &s.wit {
println!(" wit: {} / {}", wit.package, wit.world);
if !wit.imports.is_empty() {
println!(" imports: {}", wit.imports.join(", "));
}
if !wit.exports.is_empty() {
println!(" exports: {}", wit.exports.join(", "));
}
}
for f in &s.inputs {
println!(" in {}: {:?}", f.name, f.ty);
}
for f in &s.outputs {
println!(" out {}: {:?}", f.name, f.ty);
}
}
}
println!();
println!("Matches ({}):", snap.matches.len());
if snap.matches.is_empty() {
println!(" (ninguno)");
} else {
for m in &snap.matches {
let pin_marker = if m.pinned { "📌" } else { " " };
println!(
" {} {}.{}{}.{} via {:?}",
pin_marker,
m.consumer_label,
m.consumer.flow_name,
m.producer_label,
m.producer.flow_name,
m.via
);
}
}
Ok(())
}
@@ -0,0 +1,48 @@
//! Cliente admin: lee un `StatusSnapshot` desde un socket admin.
use std::path::Path;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use crate::snapshot::StatusSnapshot;
#[derive(Debug, Error)]
pub enum AdminError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
#[error("respuesta vacía")]
Empty,
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
/// Conecta al socket admin, lee la línea JSON y deserializa.
pub async fn query(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
let stream = UnixStream::connect(path).await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Err(AdminError::Empty);
}
let snapshot = serde_json::from_str(&line)?;
Ok(snapshot)
}
/// Variante sync de [`query`] para callers que no tienen runtime tokio
/// (típicamente: GUIs con su propio executor, como GPUI).
pub fn query_blocking(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
use std::io::{BufRead, BufReader as StdBufReader};
use std::os::unix::net::UnixStream as StdUnixStream;
let stream = StdUnixStream::connect(path)?;
let mut reader = StdBufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Err(AdminError::Empty);
}
let snapshot = serde_json::from_str(&line)?;
Ok(snapshot)
}
+23
View File
@@ -0,0 +1,23 @@
//! `brahman-admin` — observabilidad del broker.
//!
//! Expone un Unix socket separado (no se mezcla con el handshake) en el
//! que cada conexión recibe un `StatusSnapshot` JSON y se cierra. Es
//! single-shot por conexión: pensado para herramientas como
//! `brahman-status`, dashboards y health-checks.
//!
//! Wire format: una línea JSON por conexión, terminada en `\n`. Esto
//! hace trivial inspeccionar con `nc` o `socat` además del cliente
//! tipado de este crate.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod client;
pub mod server;
pub mod snapshot;
pub mod transport;
pub use snapshot::StatusSnapshot;
/// Versión del crate de admin.
pub const ADMIN_VERSION: &str = env!("CARGO_PKG_VERSION");
+110
View File
@@ -0,0 +1,110 @@
//! Servidor admin: emite un `StatusSnapshot` JSON por conexión y cierra.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use brahman_broker::Broker;
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Mutex;
use tracing::warn;
use crate::snapshot::StatusSnapshot;
/// Configuración del servidor admin.
#[derive(Debug, Clone, Default)]
pub struct AdminConfig {
/// `true` si el Init está atado al servidor que aloja este admin.
pub init_attached: bool,
/// Contexto operativo del broker, espejado en el snapshot.
pub current_context: Option<String>,
}
/// Servidor admin escuchando en un Unix socket.
pub struct AdminServer {
listener: UnixListener,
socket_path: PathBuf,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
}
impl AdminServer {
/// Crea el listener. Si `path` existe, lo elimina (asume socket stale).
pub fn bind(
path: impl Into<PathBuf>,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
broker,
config,
})
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Loop de aceptación: cada conexión recibe un snapshot y se cierra.
pub async fn run(self) -> std::io::Result<()> {
loop {
let (stream, _addr) = self.listener.accept().await?;
let broker = self.broker.clone();
let config = self.config.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, broker, config).await {
warn!(error = %e, "admin conn falló");
}
});
}
}
}
impl Drop for AdminServer {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar admin socket");
}
}
}
}
async fn handle_conn(
mut stream: UnixStream,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<()> {
let snapshot = build_snapshot(&broker, &config).await;
let mut json = serde_json::to_string(&snapshot)?;
json.push('\n');
stream.write_all(json.as_bytes()).await?;
stream.shutdown().await?;
Ok(())
}
async fn build_snapshot(broker: &Arc<Mutex<Broker>>, config: &AdminConfig) -> StatusSnapshot {
let b = broker.lock().await;
let sessions: Vec<_> = b.cards().cloned().collect();
let matches = b.all_matches();
StatusSnapshot {
server_version: crate::ADMIN_VERSION.to_string(),
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
init_attached: config.init_attached,
current_context: config.current_context.clone(),
sessions,
matches,
}
}
@@ -0,0 +1,24 @@
//! Tipos del snapshot que el admin server emite.
use brahman_broker::{BrokeredCard, Match};
use serde::{Deserialize, Serialize};
/// Snapshot completo del estado del Init en un instante.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusSnapshot {
/// Versión del crate del Init que respondió.
pub server_version: String,
/// Versión del protocolo brahman.
pub protocol_version: String,
/// `true` si el Init está atado al servidor.
pub init_attached: bool,
/// Contexto operativo activo del broker (p. ej. `"test"`, `"prod"`).
/// `None` si no hay contexto configurado — los biases per-contexto
/// declarados en las Cards quedan inactivos.
#[serde(default)]
pub current_context: Option<String>,
/// Cards actualmente registradas (sesiones vivas).
pub sessions: Vec<BrokeredCard>,
/// Matches consumer↔producer derivados del set actual.
pub matches: Vec<Match>,
}
@@ -0,0 +1,20 @@
//! Convenciones de transporte para el socket admin.
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket admin.
pub const SOCKET_ENV: &str = "BRAHMAN_ADMIN_SOCKET";
/// Nombre del socket admin dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-admin.sock";
/// Ruta canónica al socket admin del Init.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "brahman-broker"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — broker de tipos: empareja productores↔consumidores por TypeRef con matching híbrido (exact + structural) y prioridad."
[dependencies]
brahman-card = { path = "../brahman-card" }
serde = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
+995
View File
@@ -0,0 +1,995 @@
//! `brahman-broker` — empareja productores y consumidores por tipo de flujo.
//!
//! El broker indexa [`brahman_card::Card`]s registradas por `SessionId` y,
//! para cada `flow.input` de un consumidor, busca el `flow.output`
//! compatible de mejor calidad entre los demás. Tres ejes:
//!
//! 1. **Estrategia de matching** ([`MatchStrategy`]):
//! - `Exact`: igualdad estricta de [`brahman_card::TypeRef`].
//! - `Structural`: misma forma (mismo `package` + `name` para Wit;
//! ignora `interface`).
//! - `ExactThenStructural`: prefiere exact; cae en structural si no hay.
//!
//! 2. **Override `pin_to`**: si el consumidor declara `pin_to = "label"`,
//! el broker prefiere productores cuya Card tenga ese `label` (siempre
//! que el tipo siga matcheando). Si la pista no resuelve, cae en
//! matching por tipo normal.
//!
//! 3. **Prioridad**: empate de tipo se resuelve por
//! [`brahman_card::Priority`] del productor (mayor gana). Empate de
//! prioridad se resuelve lexicográficamente por `label` (estable y
//! determinista).
//!
//! El broker es **stateless w.r.t. routes**: cada `find_producer_for` o
//! `all_matches` se calcula bajo demanda. La única persistencia es el
//! índice de Cards registradas. Esto permite re-evaluar matches cuando
//! cambia el set sin invalidar caches.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use std::path::PathBuf;
use brahman_card::{
Card, CardKind, CardReference, ContextBias, DataFacet, Flow, Lifecycle, Priority, TypeRef,
WitInterface,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
/// Identificador de sesión emitido por el handshake. Idéntico al usado por
/// `brahman-handshake` (no es un re-export para evitar la dependencia).
pub type SessionId = Ulid;
/// Estrategia de matching de tipos.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MatchStrategy {
/// Igualdad estricta de `TypeRef`.
Exact,
/// Misma forma: para `Wit`, mismo `package` + `name`; para
/// `Primitive`, mismo `name`.
Structural,
/// Híbrido: intenta `Exact` primero; si no matchea, `Structural`.
/// Reporta cuál estrategia ganó en [`Match::via`].
#[default]
ExactThenStructural,
}
/// Configuración del broker.
#[derive(Debug, Clone, Default)]
pub struct BrokerConfig {
pub strategy: MatchStrategy,
/// Contexto operativo activo. Si una Card declara un
/// `priority_contexts.<this>`, ese bias se aplica durante el match.
/// `None` = sin biases per-contexto, sólo se usa lo estático.
pub current_context: Option<String>,
}
/// Vista mínima de una Card que el broker necesita.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokeredCard {
pub session: SessionId,
pub label: String,
pub lifecycle: Lifecycle,
pub priority: Priority,
pub inputs: Vec<Flow>,
pub outputs: Vec<Flow>,
/// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico.
pub wit: Option<WitInterface>,
/// Biases per-contexto, propagados desde `Card.priority_contexts`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub priority_contexts: BTreeMap<String, ContextBias>,
/// Naturaleza de la entidad. Diferencia procesos (Ente) de
/// agrupaciones de datos (Data — p. ej. Mónadas Nouser).
#[serde(default)]
pub kind: CardKind,
/// Faceta de datos cuando `kind != Ente`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<DataFacet>,
/// Socket de servicio (data plane) si lo declara la Card.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_socket: Option<PathBuf>,
/// Referencias a otras Cards (relaciones declaradas por esta Card).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<CardReference>,
}
impl BrokeredCard {
fn from_card(session: SessionId, card: &Card, wit: Option<WitInterface>) -> Self {
Self {
session,
label: card.label.clone(),
lifecycle: card.lifecycle,
priority: card.priority,
inputs: card.flow.input.clone(),
outputs: card.flow.output.clone(),
wit,
priority_contexts: card.priority_contexts.clone(),
kind: card.kind,
data: card.data.clone(),
service_socket: card.service_socket.clone(),
references: card.references.clone(),
}
}
}
/// Punto extremo de un flujo: qué sesión + nombre del flow dentro de su Card.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Endpoint {
pub session: SessionId,
pub flow_name: String,
}
/// Match concreto entre un consumidor y un productor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Match {
pub consumer: Endpoint,
pub consumer_label: String,
pub producer: Endpoint,
pub producer_label: String,
/// Tipo del flow (lado consumidor — lado productor coincide en
/// estrategia Exact, puede diferir en `interface` en Structural).
pub ty: TypeRef,
/// Estrategia que efectivamente matcheó.
pub via: MatchStrategy,
/// `true` si el match fue resuelto por `pin_to` y no por type-search.
pub pinned: bool,
}
// =====================================================================
// Broker
// =====================================================================
/// El broker. Registra Cards por SessionId, computa matches bajo demanda.
#[derive(Debug, Clone, Default)]
pub struct Broker {
cards: BTreeMap<SessionId, BrokeredCard>,
config: BrokerConfig,
}
impl Broker {
pub fn new(config: BrokerConfig) -> Self {
Self {
cards: BTreeMap::new(),
config,
}
}
/// Registra una Card con su WIT opcional. Devuelve `Some(prev)` si
/// reemplazó una existente. Pasar `None` en `wit` indica módulo
/// agnóstico (sin contrato WIT extraído).
pub fn register(
&mut self,
session: SessionId,
card: &Card,
wit: Option<WitInterface>,
) -> Option<BrokeredCard> {
self.cards
.insert(session, BrokeredCard::from_card(session, card, wit))
}
/// Quita una Card por sesión.
pub fn unregister(&mut self, session: SessionId) -> Option<BrokeredCard> {
self.cards.remove(&session)
}
/// Cardinalidad del registro.
pub fn len(&self) -> usize {
self.cards.len()
}
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
/// Iterador sobre las sesiones registradas.
pub fn sessions(&self) -> impl Iterator<Item = SessionId> + '_ {
self.cards.keys().copied()
}
/// Iterador sobre las Cards registradas (vista compartida).
pub fn cards(&self) -> impl Iterator<Item = &BrokeredCard> + '_ {
self.cards.values()
}
/// Busca el mejor productor para un input específico de un consumidor.
///
/// Algoritmo:
/// 1. Resuelve el flow input en el consumidor.
/// 2. Si tiene `pin_to`, prefiere productores con ese `label` que
/// matcheen el tipo (cualquier estrategia configurada).
/// 3. Si no hay pin_to o la pista falló, escanea todos los outputs
/// de las otras Cards. Filtra por compatibilidad de tipo.
/// 4. Ordena por (priority desc, label asc) y devuelve el primero.
pub fn find_producer_for(&self, consumer: SessionId, input_name: &str) -> Option<Match> {
let cons = self.cards.get(&consumer)?;
let input = cons.inputs.iter().find(|f| f.name == input_name)?;
// pin_to efectivo: bias del contexto activo (si la Card declara
// override consumer-side) > pin_to estático del Flow.
let context_pin = self
.context_bias(cons)
.and_then(|b| b.pin_to.as_deref());
let effective_pin = context_pin.or(input.pin_to.as_deref());
if let Some(pin) = effective_pin {
for prod in self.cards.values() {
if prod.session == consumer || prod.label != pin {
continue;
}
for out in &prod.outputs {
if let Some(via) = self.types_match(&input.ty, &out.ty) {
return Some(self.make_match(cons, prod, input, out, via, true));
}
}
}
// Fall through: pin no resuelto, type-search general.
}
let mut candidates: Vec<(&BrokeredCard, &Flow, MatchStrategy)> = Vec::new();
for prod in self.cards.values() {
if prod.session == consumer {
continue;
}
for out in &prod.outputs {
if let Some(via) = self.types_match(&input.ty, &out.ty) {
candidates.push((prod, out, via));
}
}
}
// Sort por (effective priority desc, label asc). El bias del
// contexto puede subir o bajar la priority del productor.
candidates.sort_by(|(a, _, _), (b, _, _)| {
self.effective_priority(b)
.cmp(&self.effective_priority(a))
.then_with(|| a.label.cmp(&b.label))
});
let (prod, out, via) = candidates.into_iter().next()?;
Some(self.make_match(cons, prod, input, out, via, false))
}
/// Devuelve el `ContextBias` que aplica a este Card en el contexto
/// activo (si lo hay).
fn context_bias<'a>(&self, card: &'a BrokeredCard) -> Option<&'a ContextBias> {
self.config
.current_context
.as_ref()
.and_then(|ctx| card.priority_contexts.get(ctx))
}
/// Priority efectiva del Card como productor, considerando el bias
/// del contexto activo. El offset se clampa a `[Low=0, Critical=3]`.
fn effective_priority(&self, card: &BrokeredCard) -> i16 {
let base = priority_value(card.priority);
let offset = self
.context_bias(card)
.map(|b| b.priority_offset as i16)
.unwrap_or(0);
(base + offset).clamp(0, 3)
}
/// Calcula todos los matches consumer→producer en el set actual.
/// Útil para introspección o para que el Admin emita rutas en lote.
pub fn all_matches(&self) -> Vec<Match> {
let mut out = Vec::new();
for cons in self.cards.values() {
for input in &cons.inputs {
if let Some(m) = self.find_producer_for(cons.session, &input.name) {
out.push(m);
}
}
}
out
}
fn types_match(&self, consumer_ty: &TypeRef, producer_ty: &TypeRef) -> Option<MatchStrategy> {
match self.config.strategy {
MatchStrategy::Exact => exact_match(consumer_ty, producer_ty).then_some(MatchStrategy::Exact),
MatchStrategy::Structural => {
structural_match(consumer_ty, producer_ty).then_some(MatchStrategy::Structural)
}
MatchStrategy::ExactThenStructural => {
if exact_match(consumer_ty, producer_ty) {
Some(MatchStrategy::Exact)
} else if structural_match(consumer_ty, producer_ty) {
Some(MatchStrategy::Structural)
} else {
None
}
}
}
}
fn make_match(
&self,
cons: &BrokeredCard,
prod: &BrokeredCard,
input: &Flow,
output: &Flow,
via: MatchStrategy,
pinned: bool,
) -> Match {
Match {
consumer: Endpoint {
session: cons.session,
flow_name: input.name.clone(),
},
consumer_label: cons.label.clone(),
producer: Endpoint {
session: prod.session,
flow_name: output.name.clone(),
},
producer_label: prod.label.clone(),
ty: input.ty.clone(),
via,
pinned,
}
}
}
// =====================================================================
// Predicados de matching (libres, testeables aislados)
// =====================================================================
fn priority_value(p: Priority) -> i16 {
match p {
Priority::Low => 0,
Priority::Normal => 1,
Priority::High => 2,
Priority::Critical => 3,
}
}
fn exact_match(a: &TypeRef, b: &TypeRef) -> bool {
a == b
}
fn structural_match(a: &TypeRef, b: &TypeRef) -> bool {
match (a, b) {
(TypeRef::Primitive { name: na }, TypeRef::Primitive { name: nb }) => na == nb,
(
TypeRef::Wit {
package: pa, name: na, ..
},
TypeRef::Wit {
package: pb, name: nb, ..
},
) => pa == pb && na == nb,
_ => false,
}
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
use brahman_card::{Card, Flows, Payload, Supervision, CARD_SCHEMA_VERSION};
fn card(label: &str, priority: Priority, flows: Flows) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
priority,
flow: flows,
..Default::default()
}
}
fn prim(name: &str) -> TypeRef {
TypeRef::Primitive { name: name.into() }
}
fn wit(pkg: &str, iface: Option<&str>, name: &str) -> TypeRef {
TypeRef::Wit {
package: pkg.into(),
interface: iface.map(|s| s.into()),
name: name.into(),
}
}
fn flow(name: &str, ty: TypeRef, pin: Option<&str>) -> Flow {
Flow {
name: name.into(),
ty,
pin_to: pin.map(|s| s.into()),
}
}
#[test]
fn exact_match_same_typeref() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Exact,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("results", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("query", prim("string"), None)],
output: vec![],
},
);
let s_prod = Ulid::new();
let s_cons = Ulid::new();
b.register(s_prod, &producer, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "query").expect("match");
assert_eq!(m.producer_label, "dht");
assert_eq!(m.via, MatchStrategy::Exact);
assert!(!m.pinned);
}
#[test]
fn structural_ignores_interface() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Structural,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
output: vec![],
},
);
let s_prod = Ulid::new();
let s_cons = Ulid::new();
b.register(s_prod, &producer, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.via, MatchStrategy::Structural);
}
#[test]
fn exact_strategy_rejects_interface_mismatch() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Exact,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
output: vec![],
},
);
b.register(Ulid::new(), &producer, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
assert!(b.find_producer_for(s_cons, "in").is_none());
}
#[test]
fn exact_then_structural_prefers_exact() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::ExactThenStructural,
current_context: None,
});
// Productor 1: match estructural (interface diferente)
let p_struct = card(
"dht-cache",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
},
);
// Productor 2: match exact (interface igual)
let p_exact = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
output: vec![],
},
);
b.register(Ulid::new(), &p_struct, None);
b.register(Ulid::new(), &p_exact, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
// El exact gana incluso si tiene priority igual: por estrategia.
assert_eq!(m.producer_label, "dht");
assert_eq!(m.via, MatchStrategy::Exact);
}
#[test]
fn pin_to_overrides_type_search() {
let mut b = Broker::new(BrokerConfig::default());
// Dos productores que producen el mismo tipo.
let p1 = card(
"dht-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p2 = card(
"dht-test",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("dht-test"))],
output: vec![],
},
);
b.register(Ulid::new(), &p1, None);
b.register(Ulid::new(), &p2, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "dht-test");
assert!(m.pinned);
}
#[test]
fn pin_to_unresolvable_falls_back_to_type_match() {
let mut b = Broker::new(BrokerConfig::default());
let p = card(
"real-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("nonexistent"))],
output: vec![],
},
);
b.register(Ulid::new(), &p, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "real-dht");
assert!(!m.pinned);
}
#[test]
fn priority_breaks_ties() {
let mut b = Broker::new(BrokerConfig::default());
let p_low = card(
"z-dht",
Priority::Low,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p_high = card(
"a-dht",
Priority::High,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
b.register(Ulid::new(), &p_low, None);
b.register(Ulid::new(), &p_high, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "a-dht"); // priority High > Low
}
#[test]
fn label_alpha_breaks_priority_ties() {
let mut b = Broker::new(BrokerConfig::default());
let p1 = card(
"z-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p2 = card(
"a-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
b.register(Ulid::new(), &p1, None);
b.register(Ulid::new(), &p2, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "a-dht"); // alfabético gana
}
#[test]
fn unregister_removes_producer() {
let mut b = Broker::new(BrokerConfig::default());
let p = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let s_p = Ulid::new();
b.register(s_p, &p, None);
let s_c = Ulid::new();
b.register(s_c, &consumer, None);
assert!(b.find_producer_for(s_c, "in").is_some());
b.unregister(s_p);
assert!(b.find_producer_for(s_c, "in").is_none());
}
#[test]
fn no_self_loops() {
let mut b = Broker::new(BrokerConfig::default());
let same = card(
"echo",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![flow("out", prim("string"), None)],
},
);
let s = Ulid::new();
b.register(s, &same, None);
// Solo una Card registrada — no hay otra que produzca string.
assert!(b.find_producer_for(s, "in").is_none());
}
#[test]
fn all_matches_lists_pairs() {
let mut b = Broker::new(BrokerConfig::default());
let dht = card(
"dht",
Priority::Normal,
Flows {
input: vec![flow("query", prim("string"), None)],
output: vec![flow("results", prim("bytes"), None)],
},
);
let ui = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("data", prim("bytes"), None)],
output: vec![flow("user-input", prim("string"), None)],
},
);
b.register(Ulid::new(), &dht, None);
b.register(Ulid::new(), &ui, None);
let matches = b.all_matches();
assert_eq!(matches.len(), 2);
// dht.query ← ui.user-input y ui.data ← dht.results
let pairs: Vec<_> = matches
.iter()
.map(|m| (m.consumer_label.as_str(), m.producer_label.as_str()))
.collect();
assert!(pairs.contains(&("dht", "ui")));
assert!(pairs.contains(&("ui", "dht")));
}
// ===========================================================
// Priority contexts
// ===========================================================
#[test]
fn context_priority_offset_lifts_producer_above_alphabetic_winner() {
// Sin contexto, "a-prod" gana contra "b-prod" (alfabético).
// En contexto "test", b-prod tiene offset +1 → debería ganar.
let mut a_prod = card(
"a-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
a_prod.priority_contexts = std::collections::BTreeMap::new(); // explícito vacío
let mut b_prod = card(
"b-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
b_prod.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
priority_offset: 1,
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let s_cons = Ulid::new();
// Caso 1: sin contexto → a-prod gana (alfabético).
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: None,
});
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "a-prod");
// Caso 2: contexto "test" → b-prod gana por offset +1.
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("test".into()),
});
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "b-prod");
}
#[test]
fn context_pin_to_overrides_static_pin() {
// Consumer pinea estático a "real-dht", pero en contexto "test"
// declara override a "mock-dht".
let real = card(
"real-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let mock = card(
"mock-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let mut consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("real-dht"))],
output: vec![],
},
);
consumer.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: Some("mock-dht".into()),
priority_offset: 0,
},
);
let s_cons = Ulid::new();
// Caso 1: sin contexto → static pin gana ("real-dht").
let mut b = Broker::new(BrokerConfig::default());
b.register(Ulid::new(), &real, None);
b.register(Ulid::new(), &mock, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "real-dht");
assert!(m.pinned);
// Caso 2: contexto "test" → context override gana ("mock-dht").
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("test".into()),
});
b.register(Ulid::new(), &real, None);
b.register(Ulid::new(), &mock, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "mock-dht");
assert!(m.pinned);
}
#[test]
fn unknown_context_no_op() {
// Si la Card declara biases para "test" pero el broker está en
// "prod", los biases no aplican.
let mut b_prod = card(
"b-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
b_prod.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
priority_offset: 5,
},
);
let a_prod = card(
"a-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("prod".into()),
});
let s_cons = Ulid::new();
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
// En contexto "prod" sin biases declarados, gana por alfabético.
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "a-prod");
}
#[test]
fn priority_offset_clamps_to_critical() {
// Offset enorme no debe hacer overflow ni saltar fuera del rango.
let mut prod = card(
"p",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
prod.priority_contexts.insert(
"x".into(),
ContextBias {
pin_to: None,
priority_offset: 100,
},
);
let b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("x".into()),
});
let bc = BrokeredCard::from_card(Ulid::new(), &prod, None);
// effective_priority debe estar clampada a 3 (Critical), no 101.
assert_eq!(b.effective_priority(&bc), 3);
}
}
@@ -0,0 +1,21 @@
[package]
name = "brahman-card-wit"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — extractor opcional: parsea contratos WIT y devuelve `WitInterface` listo para acoplar a una `Card`."
[dependencies]
brahman-card = { path = "../brahman-card" }
wit-parser = "0.230"
thiserror = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
[[example]]
name = "brahman-wit-info"
path = "examples/brahman-wit-info.rs"
@@ -0,0 +1,45 @@
//! `brahman-wit-info` — inspecciona un archivo WIT y lista sus worlds.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-card-wit --example brahman-wit-info -- shared_wit/protocol.wit
//! ```
use std::process::ExitCode;
fn main() -> ExitCode {
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("uso: brahman-wit-info <ruta.wit>");
return ExitCode::from(2);
}
};
let worlds = match brahman_card_wit::parse_wit_file(&path) {
Ok(w) => w,
Err(e) => {
eprintln!("error parseando {path}: {e}");
return ExitCode::from(1);
}
};
if worlds.is_empty() {
println!("(ningún world declarado)");
return ExitCode::SUCCESS;
}
println!("{} world(s):", worlds.len());
for w in &worlds {
println!();
println!(" package: {}", w.package);
println!(" world: {}", w.world);
if !w.imports.is_empty() {
println!(" imports: {}", w.imports.join(", "));
}
if !w.exports.is_empty() {
println!(" exports: {}", w.exports.join(", "));
}
}
ExitCode::SUCCESS
}
+165
View File
@@ -0,0 +1,165 @@
//! `brahman-card-wit` — extractor de contratos WIT.
//!
//! Crate **opcional** (no es dep de `brahman-card`). Parsea texto WIT
//! mediante [`wit-parser`] y devuelve una lista de [`WitInterface`]
//! (uno por `world`) lista para acoplarse a una [`brahman_card::Card`]
//! cuando se construye una [`brahman_card::ResolvedCard`].
//!
//! Casos de uso:
//!
//! - El Init lee `<modulo>/wit/protocol.wit` durante el descubrimiento
//! y lo combina con la Card del módulo para obtener una
//! `ResolvedCard::from_conscious(card, wit)`.
//! - Tooling (`brahman-wit-info`) inspecciona un `.wit` y muestra
//! sus mundos, exports e imports.
//!
//! No depende de `wasm-tools`/`wit-component` — sólo del parser texto.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::path::{Path, PathBuf};
use brahman_card::WitInterface;
use thiserror::Error;
use wit_parser::{Resolve, WorldKey};
#[derive(Debug, Error)]
pub enum WitError {
#[error("parse: {0}")]
Parse(String),
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
}
/// Parsea WIT desde una string. Devuelve un `WitInterface` por cada
/// `world` declarado.
pub fn parse_wit(source: &str) -> Result<Vec<WitInterface>, WitError> {
parse_with_path(source, Path::new("inline.wit"))
}
/// Parsea WIT desde un archivo. Útil para `module/wit/protocol.wit`.
pub fn parse_wit_file(path: impl AsRef<Path>) -> Result<Vec<WitInterface>, WitError> {
let p = path.as_ref();
let source = std::fs::read_to_string(p)?;
parse_with_path(&source, p)
}
fn parse_with_path(source: &str, path: &Path) -> Result<Vec<WitInterface>, WitError> {
let mut resolve = Resolve::new();
let path_buf: PathBuf = path.to_path_buf();
resolve
.push_str(&path_buf, source)
.map_err(|e| WitError::Parse(e.to_string()))?;
let mut out = Vec::new();
for (_pkg_id, pkg) in resolve.packages.iter() {
let pkg_name = pkg.name.to_string();
for (_name, &world_id) in &pkg.worlds {
let world = &resolve.worlds[world_id];
let exports = collect_keys(world.exports.iter().map(|(k, _)| k), &resolve);
let imports = collect_keys(world.imports.iter().map(|(k, _)| k), &resolve);
out.push(WitInterface {
package: pkg_name.clone(),
world: world.name.clone(),
exports,
imports,
});
}
}
Ok(out)
}
fn collect_keys<'a, I>(keys: I, resolve: &Resolve) -> Vec<String>
where
I: Iterator<Item = &'a WorldKey>,
{
keys.map(|k| match k {
WorldKey::Name(n) => n.clone(),
WorldKey::Interface(id) => resolve.interfaces[*id]
.name
.clone()
.unwrap_or_else(|| format!("<interface#{}>", id.index())),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
package brahman:test@0.1.0;
interface handshake {
hello: func() -> result<_, string>;
}
interface lifecycle {
report: func();
}
world module {
import handshake;
import lifecycle;
export run: func() -> result<_, string>;
}
"#;
#[test]
fn parses_inline_wit() {
let worlds = parse_wit(SAMPLE).unwrap();
assert_eq!(worlds.len(), 1, "esperaba un único world");
let w = &worlds[0];
assert!(w.package.starts_with("brahman:test"));
assert_eq!(w.world, "module");
assert!(
w.imports.iter().any(|i| i == "handshake"),
"imports={:?}",
w.imports
);
assert!(
w.imports.iter().any(|i| i == "lifecycle"),
"imports={:?}",
w.imports
);
assert!(
w.exports.iter().any(|e| e == "run"),
"exports={:?}",
w.exports
);
}
#[test]
fn parses_shared_protocol() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../shared_wit/protocol.wit");
let worlds = parse_wit_file(path).unwrap();
assert!(
worlds.iter().any(|w| w.world == "module"),
"no encontró world 'module' en {:?}",
worlds.iter().map(|w| &w.world).collect::<Vec<_>>()
);
assert!(
worlds.iter().any(|w| w.world == "admin-host"),
"no encontró world 'admin-host'"
);
}
#[test]
fn parse_error_on_garbage() {
let bad = "this is not wit at all { } } ;;;;";
assert!(matches!(parse_wit(bad), Err(WitError::Parse(_))));
}
#[test]
fn empty_world_handled() {
let src = r#"
package brahman:empty@0.1.0;
world hollow {}
"#;
let worlds = parse_wit(src).unwrap();
assert_eq!(worlds.len(), 1);
assert!(worlds[0].exports.is_empty());
assert!(worlds[0].imports.is_empty());
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "brahman-card"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — Tarjeta de Presentación canónica (identidad arje + flujos tipados brahman)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
[dev-dependencies]
postcard = { workspace = true }
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "brahman-cards"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — brazo unificado: lee múltiples formatos de Card (Ente/Monad/UiModule/...) y los proyecta a una estructura canónica única."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
brahman-card = { path = "../brahman-card" }
akasha-card = { path = "../../modules/akasha/card" }
nahual-meta-schema = { path = "../../modules/nahual/libs/meta-schema" }
nickel-lang = "2.0.0"
[dev-dependencies]
serde_json = { workspace = true }
+334
View File
@@ -0,0 +1,334 @@
//! `brahman-cards` — brazo unificado de Cards.
//!
//! Brahman maneja varios formatos legítimos de "Card" (la unidad
//! declarativa que describe identidad, datos, módulos, widgets, ...).
//! Cada formato vive en su propio crate de origen y conserva su shape
//! público; lo que este crate aporta es **un único punto de entrada**
//! que sabe interpretar cada uno de ellos y proyectarlos a una sola
//! estructura interna canónica [`Card`].
//!
//! Diseño:
//!
//! ```text
//! ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
//! │ Ente JSON │ │ Monad JSON │ │ UiModule │ … futuro
//! │ (brahman- │ │ (akasha- │ │ (nakui-ui- │
//! │ card) │ │ card) │ │ schema) │
//! └─────┬───────┘ └──────┬───────┘ └──────┬──────┘
//! │ │ │
//! └────────┬────────┴────────┬────────┘
//! │ brahman-cards │
//! │ (este crate) │
//! └────────┬────────┘
//! │
//! ┌──────▼──────┐
//! │ `Card` │ ← único tipo canónico
//! │ wrapper │ que consumen UI runtime,
//! │ común + │ storage, DHT, wire.
//! │ variant │
//! │ body │
//! └─────────────┘
//! ```
//!
//! Los formatos NO se disuelven. Si en el futuro hay que soportar un
//! formato simplificado nuevo, se agrega un reader acá y nadie aguas
//! abajo se entera — siguen recibiendo `Card`.
//!
//! V1 (este commit) sólo soporta inputs JSON. La extensión a Nickel
//! (con templates de defaults vía merge nativo de Nickel) llega en un
//! commit separado para aislar la dependencia `nickel-lang-core`.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
pub use brahman_card::Card as EnteCard;
pub use akasha_card::MonadManifest;
pub use nahual_meta_schema::Module as UiModuleSpec;
/// Estructura canónica única que consumen los downstream del sistema
/// (UI runtime, storage, DHT, wire). Cada formato input se proyecta
/// a ésta vía un reader del brazo.
///
/// El wrapper común agrupa lo que TODOS los formatos comparten
/// (identidad legible + extensiones forward-compat); el body preserva
/// el typing rico de cada dominio sin colapsarlos.
// PartialEq se omite porque algunos body variants vienen de crates
// que no lo implementan (MonadManifest, nahual_meta_schema::Module).
// Si downstream necesita igualdad, comparar via JSON round-trip o
// agregar PartialEq en los crates origen.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Card {
/// Identificador opaco. String en el wrapper para no obligar a
/// los formatos a un mismo tipo concreto (Ente/Monad usan ULID,
/// UiModule usa slug human-friendly como `"sales_engine"`).
/// Cada reader documenta qué formato exige.
pub id: String,
/// Versión del schema canónico de este wrapper. Bump = romper
/// compat de los consumers downstream. Distinto de los
/// `schema_version` internos de cada body variant, que siguen
/// su propio versioning.
pub schema_version: u16,
/// Ancestro del que esta Card desciende (si aplica). Significado
/// específico al body variant (Ente: lineage del proceso; Monad:
/// split/merge de Mónada padre; UiModule: típicamente None).
#[serde(default)]
pub lineage: Option<String>,
/// Etiqueta humana legible. Cada reader la deriva del campo
/// equivalente del input (label/title/etc.).
pub label: String,
/// Campos no reconocidos del input se preservan acá. Permite
/// forward-compat: leer un input con campos nuevos no rompe la
/// carga, y volver a serializar conserva el extra.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
/// Cuerpo tipado por dominio. La elección del variant es
/// responsabilidad del reader (basada en el input shape).
pub body: CardBody,
}
/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
/// la shape del wrapper o las invariantes que comparten todos los
/// variants.
pub const CARD_SCHEMA_VERSION: u16 = 1;
/// Variantes tipadas del body de [`Card`]. Una por dominio.
///
/// **Convención de extensión**: agregar un variant nuevo aquí + un
/// reader que produzca ese variant. Los consumers que sólo manejen
/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CardBody {
/// Entidad runtime con proceso/payload/supervision (lo que era
/// `brahman_card::Card` directo).
Ente(EnteCard),
/// Agrupación semántica de archivos (Mónada de Nouser). No tiene
/// proceso; describe membership + signals semánticas (centroid,
/// keywords, lens).
Monad(MonadManifest),
/// Descriptor de módulo de UI: entities + views + menu + actions.
/// Lo que hoy lee la metainterface de Nakui desde
/// `examples/nakui-modules/<id>/module.json`.
UiModule(UiModuleSpec),
}
impl CardBody {
/// Etiqueta corta del variant — útil para mensajes de error y
/// dispatch en la UI sin necesitar match exhaustivo.
pub fn kind_name(&self) -> &'static str {
match self {
CardBody::Ente(_) => "ente",
CardBody::Monad(_) => "monad",
CardBody::UiModule(_) => "ui_module",
}
}
}
/// Errores de carga del brazo.
#[derive(Debug, Error)]
pub enum CardLoadError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("parse JSON: {0}")]
JsonParse(#[from] serde_json::Error),
#[error("ningún reader registrado matcheó el input (shape no reconocido)")]
NoMatchingReader,
#[error("reader '{reader}' falló: {message}")]
ReaderFailed { reader: &'static str, message: String },
#[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
UnsupportedExtension {
ext: String,
supported: Vec<&'static str>,
},
#[error("evaluación Nickel: {0}")]
Nickel(#[from] NickelEvalError),
}
/// Trait de reader. Cada formato implementa una instancia.
///
/// El dispatcher del brazo (`load_card`) prueba los readers en el
/// orden registrado y se queda con el primero cuyo `can_read`
/// devuelve `true`. Por eso el orden importa: poner los más
/// específicos antes que los más laxos.
pub trait CardReader: Send + Sync {
/// Nombre del reader, para mensajes de error.
fn name(&self) -> &'static str;
/// Dado un JSON Value (el input ya parseado a serde Value),
/// decide si este reader puede manejarlo. Heurística estructural
/// — el shape del input identifica el formato, no flags
/// explícitos (los inputs legacy no los tienen).
fn can_read(&self, input: &Value) -> bool;
/// Produce el [`Card`] canónico. Sólo se llama si `can_read`
/// devolvió `true`.
fn read(&self, input: Value) -> Result<Card, CardLoadError>;
}
mod nickel_eval;
mod readers;
pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
/// Path al directorio de templates Nickel canónicos shipped con el
/// crate (`crates/core/brahman-cards/templates/` en el repo).
///
/// Este directorio contiene los `*_basic.ncl` para cada body kind:
/// - `ente_basic.ncl`
/// - `monad_basic.ncl`
/// - `ui_module_basic.ncl`
///
/// Usar como path para [`BRAHMAN_CARDS_TEMPLATES_ENV`] o pasarlo
/// directo a Nickel via env. Resuelto via `CARGO_MANIFEST_DIR` —
/// funciona en `cargo test`/`cargo run` desde el workspace. Para
/// distribución del binary standalone (cuando emerja el caso de
/// uso), incluir los templates como recursos via `include_dir!` o
/// instalar el directorio junto al ejecutable.
pub fn canonical_templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")
}
/// Construye el set default de readers para inputs JSON. El orden
/// es deliberado: el más específico (UiModule, que tiene `entities`
/// y `views` simultáneamente) antes que el más laxo. Si dos readers
/// matchean, gana el primero.
pub fn default_readers() -> Vec<Box<dyn CardReader>> {
vec![
Box::new(UiModuleJsonReader),
Box::new(MonadJsonReader),
Box::new(EnteJsonReader),
]
}
/// Carga un Card desde una ruta. Detecta formato por extensión, y
/// dentro de JSON detecta el shape probando los readers default en
/// orden.
///
/// Para custom reader sets, usar [`load_card_with`].
pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
load_card_with(path, &default_readers())
}
/// Variante de [`load_card`] con readers custom. Útil para tests o
/// para apps que quieren restringir formatos soportados.
pub fn load_card_with(
path: impl AsRef<Path>,
readers: &[Box<dyn CardReader>],
) -> Result<Card, CardLoadError> {
let path = path.as_ref();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"json" => {
let bytes = std::fs::read(path)?;
let value: Value = serde_json::from_slice(&bytes)?;
dispatch_to_reader(value, readers)
}
"ncl" => {
// Nickel pipeline: leer archivo → evaluar deeply → exportar
// a JSON → parsear como Value → dispatch a los readers JSON
// estándar. Templates funcionan via los `import` nativos de
// Nickel; el evaluator resuelve relativo al input y al
// `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
let value = eval_nickel_file(path)?;
dispatch_to_reader(value, readers)
}
other => Err(CardLoadError::UnsupportedExtension {
ext: other.to_string(),
supported: vec!["json", "ncl"],
}),
}
}
/// Recorre los readers en orden, se queda con el primero que matchea
/// y delega la conversión.
fn dispatch_to_reader(
input: Value,
readers: &[Box<dyn CardReader>],
) -> Result<Card, CardLoadError> {
for r in readers {
if r.can_read(&input) {
return r.read(input);
}
}
Err(CardLoadError::NoMatchingReader)
}
/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
/// últimos dos son por compat con el layout actual de
/// `examples/nakui-modules/<id>/module.json`.
pub const DEFAULT_CARD_FILENAMES: &[&str] =
&["card.ncl", "card.json", "module.ncl", "module.json"];
/// Carga todas las Cards encontradas como subdirs inmediatos de
/// `dir`. Por cada subdir, busca los filenames convencionales (ver
/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
/// sin ningún filename matching se skipean silenciosamente — permite
/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
///
/// Devuelve las Cards en orden lexicográfico por subdir name (estable
/// across runs). NO ordena por `Card.id` — el caller decide si quiere
/// re-ordenar y/o dedupar.
///
/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
/// `CardLoadError` de un archivo encontrado (NO continúa tras el
/// primer fallo — fallo loud > corrupción silenciosa).
pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
}
/// Variante de [`load_cards_from_dir`] con filenames y readers
/// custom. Útil para apps que quieren restringir formatos o usar un
/// nombre canónico distinto.
pub fn load_cards_from_dir_with(
dir: impl AsRef<Path>,
filenames: &[&str],
readers: &[Box<dyn CardReader>],
) -> Result<Vec<Card>, CardLoadError> {
let dir = dir.as_ref();
let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
.flatten()
.filter_map(|e| {
let p = e.path();
if p.is_dir() { Some(p) } else { None }
})
.collect();
// Orden estable por subdir name — el output del brazo no debería
// depender del orden de read_dir (que varía por filesystem).
subdir_paths.sort();
let mut out: Vec<Card> = Vec::new();
for sub in subdir_paths {
for fname in filenames {
let candidate = sub.join(fname);
if candidate.exists() {
out.push(load_card_with(&candidate, readers)?);
break;
}
}
}
Ok(out)
}
@@ -0,0 +1,141 @@
//! Evaluador Nickel para inputs `.ncl`.
//!
//! El brazo de Cards lee Nickel como **fuente** y produce JSON como
//! **representación intermedia** que después dispatcha por los readers
//! estándar. Esto significa que un `.ncl` puede producir cualquier
//! variant del [`super::CardBody`] siempre que evalúe a una shape JSON
//! que alguno de los readers reconozca.
//!
//! # Templates
//!
//! Nickel soporta `import "..."` y el operador `&` de merge nativo. Un
//! Card "concreto" puede ser un template + override:
//!
//! ```nickel
//! let base = import "ente_basic.ncl" in
//! base & { id = "01ARZ...", label = "mi-ente" }
//! ```
//!
//! **Convención obligatoria del template**: las fields que el usuario
//! va a sobrescribir tienen que estar marcadas `| default` (o
//! `| optional`). Nickel rechaza el merge de dos strings/numbers
//! distintos con la misma prioridad — el `| default` baja la prioridad
//! del template y deja que el override del user gane:
//!
//! ```nickel
//! # template ui_module_basic.ncl
//! {
//! id | String | default = "TEMPLATE_ID",
//! label | String | default = "TEMPLATE_LABEL",
//! # ...
//! }
//! ```
//!
//! Resolución de imports (en orden):
//! 1. Relativo al directorio del archivo input (default de Nickel).
//! 2. `BRAHMAN_CARDS_TEMPLATES_DIR` (env). Permite tener un
//! registry global de templates accesible por nombre desnudo:
//! `import "ui_module_basic.ncl"`.
//!
//! No agregamos magic resolución por kind — el autor decide qué
//! template importa explícitamente.
use std::ffi::OsString;
use std::path::Path;
use serde_json::Value;
use thiserror::Error;
/// Variable de entorno opcional. Si está set, su path se agrega al
/// search path de imports de Nickel después del parent dir del input,
/// permitiendo `import "<nombre>.ncl"` desde cualquier ubicación.
pub const BRAHMAN_CARDS_TEMPLATES_ENV: &str = "BRAHMAN_CARDS_TEMPLATES_DIR";
/// Errores específicos del pipeline Nickel. Wrap del error de Nickel
/// formateado como texto plano (sin ANSI) + el path del input para
/// contexto.
#[derive(Debug, Error)]
pub enum NickelEvalError {
#[error("io leyendo {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("evaluación de '{path}' falló:\n{message}")]
Eval { path: String, message: String },
#[error("export a JSON de '{path}' falló:\n{message}")]
Export { path: String, message: String },
#[error("JSON exportado por Nickel no parsea de vuelta: {source}")]
JsonReparse {
#[source]
source: serde_json::Error,
},
}
/// Lee `path` (debe ser un `.ncl` válido), lo evalúa profundamente vía
/// `nickel-lang` y devuelve el resultado como `serde_json::Value`
/// listo para dispatch a un reader JSON.
///
/// El parent dir del input se agrega como import path para que
/// imports relativos tipo `import "./template.ncl"` funcionen sin
/// configuración extra. Si `BRAHMAN_CARDS_TEMPLATES_DIR` está set,
/// también se agrega.
pub fn eval_nickel_file(path: &Path) -> Result<Value, NickelEvalError> {
let path_display = path.display().to_string();
let source = std::fs::read_to_string(path).map_err(|e| NickelEvalError::Io {
path: path_display.clone(),
source: e,
})?;
let mut import_paths: Vec<OsString> = Vec::new();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
import_paths.push(parent.into());
}
}
if let Ok(reg) = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV) {
if !reg.is_empty() {
import_paths.push(reg.into());
}
}
let mut ctx = nickel_lang::Context::new()
.with_added_import_paths(import_paths)
.with_source_name(path_display.clone());
let expr = ctx
.eval_deep_for_export(&source)
.map_err(|e| NickelEvalError::Eval {
path: path_display.clone(),
message: format_nickel_error(&e),
})?;
let json_str = ctx
.expr_to_json(&expr)
.map_err(|e| NickelEvalError::Export {
path: path_display.clone(),
message: format_nickel_error(&e),
})?;
serde_json::from_str(&json_str).map_err(|e| NickelEvalError::JsonReparse { source: e })
}
/// Formatea un error de Nickel como texto plano. Usa `ErrorFormat::Text`
/// (sin ANSI) para que sea legible en logs y mensajes de UI sin
/// escape sequences.
fn format_nickel_error(err: &nickel_lang::Error) -> String {
let mut buf: Vec<u8> = Vec::new();
if err
.format(&mut buf, nickel_lang::ErrorFormat::Text)
.is_err()
{
// Si la propia formateación falla, devolvemos el Debug —
// peor mensaje que el normal pero no perdemos info.
return format!("{err:?}");
}
String::from_utf8(buf).unwrap_or_else(|_| format!("{err:?}"))
}
@@ -0,0 +1,168 @@
//! Readers V1: tres formatos JSON ya existentes en el monorepo.
//!
//! Cada reader implementa:
//! - `can_read`: heurística estructural para decidir si el JSON es
//! suyo. No requiere flag explícito en el input — los inputs legacy
//! no los tienen.
//! - `read`: deserializa el JSON al tipo del crate origen (sin tocarlo)
//! y lo envuelve en [`Card`] derivando los campos del wrapper.
//!
//! Convenciones para derivar el wrapper:
//! - `id`: del campo `id` del input (cada formato lo expone). Si es
//! ULID se serializa a string canónico.
//! - `label`: del campo `label`.
//! - `lineage`: del campo `lineage` cuando existe (Ente/Monad).
//! - `extensions`: campos JSON desconocidos respecto a la struct del
//! crate origen. Hoy lo mantenemos vacío (los crates origen ya
//! tienen sus propios `extensions` internos via `#[serde(flatten)]`)
//! — no duplicamos. Si en el futuro queremos mover el "extras" del
//! crate origen al wrapper, esta es la palanca.
use serde_json::Value;
use crate::{Card, CardBody, CardLoadError, CardReader, EnteCard, MonadManifest, UiModuleSpec, CARD_SCHEMA_VERSION};
// ============================================================================
// Ente (brahman-card)
// ============================================================================
/// Reader para el shape JSON de [`brahman_card::Card`].
///
/// Heurística de detección: el input tiene `payload` Y `supervision`
/// — son los campos requeridos del schema Ente que ningún otro
/// formato del monorepo tiene.
pub struct EnteJsonReader;
impl CardReader for EnteJsonReader {
fn name(&self) -> &'static str {
"ente-json"
}
fn can_read(&self, input: &Value) -> bool {
let obj = match input.as_object() {
Some(o) => o,
None => return false,
};
obj.contains_key("payload") && obj.contains_key("supervision")
}
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
let id = pull_string(&input, "id").unwrap_or_default();
let label = pull_string(&input, "label").unwrap_or_default();
let lineage = pull_string(&input, "lineage");
let ente: EnteCard =
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
reader: "ente-json",
message: e.to_string(),
})?;
Ok(Card {
id,
schema_version: CARD_SCHEMA_VERSION,
lineage,
label,
extensions: Default::default(),
body: CardBody::Ente(ente),
})
}
}
// ============================================================================
// Monad (akasha-card)
// ============================================================================
/// Reader para el shape JSON de [`akasha_card::MonadManifest`].
///
/// Heurística: tiene `members` (BTreeSet<FileId>) Y `cardinality`
/// (u32). La combinación es exclusiva del MonadManifest.
pub struct MonadJsonReader;
impl CardReader for MonadJsonReader {
fn name(&self) -> &'static str {
"monad-json"
}
fn can_read(&self, input: &Value) -> bool {
let obj = match input.as_object() {
Some(o) => o,
None => return false,
};
obj.contains_key("members") && obj.contains_key("cardinality")
}
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
let id = pull_string(&input, "id").unwrap_or_default();
let label = pull_string(&input, "label").unwrap_or_default();
let lineage = pull_string(&input, "lineage");
let monad: MonadManifest =
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
reader: "monad-json",
message: e.to_string(),
})?;
Ok(Card {
id,
schema_version: CARD_SCHEMA_VERSION,
lineage,
label,
extensions: Default::default(),
body: CardBody::Monad(monad),
})
}
}
// ============================================================================
// UiModule (nahual-meta-schema)
// ============================================================================
/// Reader para el shape JSON de los `module.json` de la metainterfaz
/// ([`nahual_meta_schema::Module`]).
///
/// Heurística: tiene `entities` Y `views` Y `menu`. Es el shape más
/// específico del repo, así que va primero en el orden default — si
/// matchea, ningún otro reader debería intentar.
pub struct UiModuleJsonReader;
impl CardReader for UiModuleJsonReader {
fn name(&self) -> &'static str {
"ui-module-json"
}
fn can_read(&self, input: &Value) -> bool {
let obj = match input.as_object() {
Some(o) => o,
None => return false,
};
obj.contains_key("entities") && obj.contains_key("views") && obj.contains_key("menu")
}
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
let id = pull_string(&input, "id").unwrap_or_default();
let label = pull_string(&input, "label").unwrap_or_default();
// UiModule no tiene lineage en su schema, queda None.
let module: UiModuleSpec =
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
reader: "ui-module-json",
message: e.to_string(),
})?;
Ok(Card {
id,
schema_version: CARD_SCHEMA_VERSION,
lineage: None,
label,
extensions: Default::default(),
body: CardBody::UiModule(module),
})
}
}
// ============================================================================
// Helpers
// ============================================================================
fn pull_string(v: &Value, key: &str) -> Option<String> {
v.get(key)?.as_str().map(|s| s.to_string())
}
@@ -0,0 +1,31 @@
# `ente_basic.ncl` — template canónico para Cards de tipo Ente.
#
# Use case típico: declarar una entity runtime mínima (Virtual
# payload, OneShot supervision) sobrescribiendo sólo `id` y `label`:
#
# let base = import "ente_basic.ncl" in
# base & {
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
# label = "mi-ente",
# }
#
# El brazo `brahman-cards::load_card` lo dispatcha al
# `EnteJsonReader` porque el shape resultante tiene `payload` Y
# `supervision` (los campos detect-key del reader Ente).
#
# **Convención obligatoria**: cada field que el usuario va a
# sobrescribir está marcada `| default`. Sin eso Nickel rebota el
# merge de strings/numbers no-iguales con misma prioridad.
{
schema_version | Number | default = 1,
# Identidad: el usuario casi siempre las sobrescribe.
id | String | default = "TEMPLATE_ID",
label | String | default = "TEMPLATE_LABEL",
# Runtime defaults razonables: nodo lógico sin proceso, sin
# restart. Override si querés un ente con payload Wasm/Native.
payload | default = "Virtual",
supervision | default = "OneShot",
}
@@ -0,0 +1,45 @@
# `monad_basic.ncl` — template canónico para Cards de tipo Monad.
#
# Use case típico: declarar una agrupación semántica de archivos
# (Mónada de Nouser) con metadata mínima:
#
# let base = import "monad_basic.ncl" in
# base & {
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
# label = "fotos-2026",
# members = ["01ARZ3FILE1", "01ARZ3FILE2"],
# cardinality = 2,
# }
#
# El brazo lo dispatcha al `MonadJsonReader` por la presencia
# simultánea de `members` Y `cardinality`.
{
schema_version | Number | default = 1,
# Identidad: override siempre.
id | String | default = "TEMPLATE_ID",
label | String | default = "TEMPLATE_LABEL",
# Metadata semántica: defaults vacíos. El usuario typically
# override `members` + `cardinality`, opcionalmente `summary`
# / `keywords` / `dominant_lens`.
summary | String | default = "",
keywords | default = [],
centroid | default = [],
cardinality | Number | default = 0,
entropy | Number | default = 0.0,
# Lens variants serialize lowercase (serde rename_all): grid /
# code / gallery / database / markdown / tree.
dominant_lens | default = "grid",
# Membership: vacío por default. El usuario llena con los IDs
# de archivo cuando los conoce.
members | default = [],
pins | default = [],
# Timestamps Unix ms — default 0 = "no timestamp registrado".
# Override con el momento real cuando importa.
created_at_ms | Number | default = 0,
updated_at_ms | Number | default = 0,
}
@@ -0,0 +1,35 @@
# `ui_module_basic.ncl` — template canónico para Cards de tipo
# UiModule (descriptores de módulos para metainterfaz yahweh).
#
# Use case típico: declarar un módulo nuevo sobrescribiendo `id`,
# `label`, y aportando los `entities`/`menu`/`views` propios:
#
# let base = import "ui_module_basic.ncl" in
# base & {
# id = "customers",
# label = "Clientes",
# entities = [
# { name = "Customer", label = "Cliente", fields = [...] },
# ],
# menu = [{ label = "Listar", view = "list" }],
# views = { list = { kind = "list", ... } },
# }
#
# El brazo lo dispatcha al `UiModuleJsonReader` por la presencia
# simultánea de `entities` Y `views` Y `menu`.
{
# Identidad: override siempre.
id | String | default = "TEMPLATE_ID",
label | String | default = "TEMPLATE_LABEL",
# Subtítulo opcional (tooltip en el sidebar). null por default.
description | default = null,
# Las 3 listas/maps son el **payload** real del módulo. El
# template las deja vacías para que el usuario las defina sin
# heredar nada útil-pero-equivocado de un default.
entities | default = [],
menu | default = [],
views | default = {},
}
@@ -0,0 +1,434 @@
//! Integration tests del brazo brahman-cards.
//!
//! Cubre:
//! 1. Cada reader matchea sólo el shape correcto.
//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader
//! correcto sin ambigüedad.
//! 3. Round-trip: cada formato JSON cargado produce el variant
//! esperado del Card canónico con los campos del wrapper bien
//! derivados.
//! 4. Rechazo gracioso de inputs no-matched + extensiones no
//! soportadas.
use std::collections::BTreeMap;
use brahman_cards::{
default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader,
MonadJsonReader, UiModuleJsonReader,
};
use serde_json::{json, Value};
/// Helper: dispatch in-process desde un Value, sin tocar disco.
/// Reproduce la lógica interna del dispatcher para no exigir I/O en
/// los tests.
fn dispatch(input: Value, readers: &[Box<dyn CardReader>]) -> Result<Card, CardLoadError> {
for r in readers {
if r.can_read(&input) {
return r.read(input);
}
}
Err(CardLoadError::NoMatchingReader)
}
// ===========================================================================
// Reader detection (can_read)
// ===========================================================================
#[test]
fn ui_module_reader_detects_only_ui_module_shape() {
let r = UiModuleJsonReader;
let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []});
let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"});
let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0});
assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape");
assert!(!r.can_read(&ente), "no debe matchear Ente");
assert!(!r.can_read(&monad), "no debe matchear Monad");
assert!(!r.can_read(&Value::Null), "no debe matchear non-object");
}
#[test]
fn ente_reader_detects_only_ente_shape() {
let r = EnteJsonReader;
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
let monad = json!({"members": [], "cardinality": 0});
let ui = json!({"menu": [], "views": {}, "entities": []});
assert!(r.can_read(&ente));
assert!(!r.can_read(&monad));
assert!(!r.can_read(&ui));
}
#[test]
fn monad_reader_detects_only_monad_shape() {
let r = MonadJsonReader;
let monad = json!({"members": [], "cardinality": 0});
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
let ui = json!({"menu": [], "views": {}, "entities": []});
assert!(r.can_read(&monad));
assert!(!r.can_read(&ente));
assert!(!r.can_read(&ui));
}
// ===========================================================================
// Dispatch + variant projection
// ===========================================================================
#[test]
fn loads_ui_module_to_card_ui_module_variant() {
let input = json!({
"id": "sales_engine",
"label": "Ventas",
"description": "Demo",
"entities": [],
"menu": [{"label": "Stock", "view": "stock_list"}],
"views": {
"stock_list": {
"kind": "list",
"title": "Stock",
"entity": "Stock",
"columns": []
}
}
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, "sales_engine");
assert_eq!(card.label, "Ventas");
assert!(card.lineage.is_none(), "UiModule sin lineage");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
assert_eq!(m.id, "sales_engine");
assert_eq!(m.menu.len(), 1);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
#[test]
fn loads_ente_to_card_ente_variant() {
// Ulid mínimo: 26 chars Crockford. Usamos uno conocido.
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let input = json!({
"schema_version": 1,
"id": ulid,
"label": "test-ente",
"payload": "Virtual",
"supervision": "OneShot"
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, ulid);
assert_eq!(card.label, "test-ente");
assert_eq!(card.body.kind_name(), "ente");
match card.body {
CardBody::Ente(e) => {
assert_eq!(e.label, "test-ente");
assert_eq!(e.id.to_string(), ulid);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
#[test]
fn loads_monad_to_card_monad_variant() {
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1";
let input = json!({
"schema_version": 1,
"id": ulid,
"label": "test-monad",
"members": [],
"cardinality": 0,
"created_at_ms": 0,
"updated_at_ms": 0
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, ulid);
assert_eq!(card.label, "test-monad");
assert_eq!(card.body.kind_name(), "monad");
match card.body {
CardBody::Monad(m) => {
assert_eq!(m.label, "test-monad");
assert_eq!(m.cardinality, 0);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
// ===========================================================================
// Negative cases
// ===========================================================================
#[test]
fn rejects_input_no_matching_reader() {
let input = json!({"random": "shape", "without": "fingerprint"});
let err = dispatch(input, &default_readers()).unwrap_err();
assert!(
matches!(err, CardLoadError::NoMatchingReader),
"expected NoMatchingReader, got {err:?}"
);
}
#[test]
fn rejects_non_object_input() {
let input = json!([1, 2, 3]);
let err = dispatch(input, &default_readers()).unwrap_err();
assert!(matches!(err, CardLoadError::NoMatchingReader));
}
#[test]
fn ui_module_takes_priority_when_shape_overlaps_partial() {
// Sanity del orden: si alguien armara un input híbrido con
// `menu`+`views`+`entities` Y también `payload`+`supervision`,
// el UiModuleReader (primero en orden) debería ganar. Esto no
// debería ocurrir con inputs reales pero defendemos el contrato
// de orden documentado.
let input = json!({
"id": "weird",
"label": "Weird",
"menu": [],
"views": {},
"entities": [],
"payload": "Virtual",
"supervision": "OneShot"
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(
card.body.kind_name(),
"ui_module",
"el UiModuleReader debería ganar por orden"
);
}
// ===========================================================================
// load_card desde disco (e2e fino)
// ===========================================================================
#[test]
fn load_card_from_disk_round_trip_ui_module() {
let tmp = tempfile_path("ui_module.json");
let input = json!({
"id": "demo",
"label": "Demo",
"entities": [],
"menu": [],
"views": {}
});
std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap();
let card = load_card_with(&tmp, &default_readers()).expect("load ok");
assert_eq!(card.body.kind_name(), "ui_module");
assert_eq!(card.id, "demo");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn load_card_rejects_unsupported_extension() {
let tmp = tempfile_path("foo.toml");
std::fs::write(&tmp, b"[anything]\nx = 1").unwrap();
let err = load_card_with(&tmp, &default_readers()).unwrap_err();
match err {
CardLoadError::UnsupportedExtension { ext, supported } => {
assert_eq!(ext, "toml");
assert!(supported.contains(&"json"));
}
other => panic!("expected UnsupportedExtension, got {other:?}"),
}
let _ = std::fs::remove_file(&tmp);
}
// ===========================================================================
// Custom reader sets
// ===========================================================================
#[test]
fn custom_reader_set_can_restrict_supported_formats() {
// Sólo Ente: un input Monad debería rechazarse.
let only_ente: Vec<Box<dyn CardReader>> = vec![Box::new(EnteJsonReader)];
let monad_input = json!({"members": [], "cardinality": 0});
let err = dispatch(monad_input, &only_ente).unwrap_err();
assert!(matches!(err, CardLoadError::NoMatchingReader));
}
// ===========================================================================
// Wrapper field invariants
// ===========================================================================
#[test]
fn extensions_field_starts_empty_in_v1() {
// Documented: V1 no mueve el "extras" del crate origen al
// wrapper.extensions. Si esto cambia, este test se rompe como
// signal para actualizar el doc de readers.rs.
let input = json!({
"id": "demo",
"label": "Demo",
"entities": [],
"menu": [],
"views": {}
});
let card = dispatch(input, &default_readers()).unwrap();
assert_eq!(card.extensions, BTreeMap::new());
}
// ===========================================================================
// load_cards_from_dir (subdir walking)
// ===========================================================================
#[test]
fn load_cards_from_dir_walks_subdirs_and_finds_module_json() {
let root = unique_dir("dir-walk");
// Subdir A: tiene module.json (UiModule).
let a = root.join("alpha");
std::fs::create_dir(&a).unwrap();
std::fs::write(
a.join("module.json"),
serde_json::to_vec_pretty(&json!({
"id": "alpha",
"label": "Alpha",
"entities": [],
"menu": [],
"views": {}
}))
.unwrap(),
)
.unwrap();
// Subdir B: tiene module.json (UiModule).
let b = root.join("bravo");
std::fs::create_dir(&b).unwrap();
std::fs::write(
b.join("module.json"),
serde_json::to_vec_pretty(&json!({
"id": "bravo",
"label": "Bravo",
"entities": [],
"menu": [],
"views": {}
}))
.unwrap(),
)
.unwrap();
// Subdir C: NO tiene ninguno de los filenames convencionales —
// se debe skipear sin error.
let c = root.join("charlie");
std::fs::create_dir(&c).unwrap();
std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap();
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
ids,
vec!["alpha", "bravo"],
"orden lexicográfico por subdir name"
);
for c in &cards {
assert_eq!(c.body.kind_name(), "ui_module");
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() {
let root = unique_dir("dir-prefer");
let sub = root.join("only");
std::fs::create_dir(&sub).unwrap();
// Ambos archivos existen; el .ncl debería ganar.
std::fs::write(
sub.join("card.ncl"),
r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#,
)
.unwrap();
std::fs::write(
sub.join("card.json"),
serde_json::to_vec(&json!({
"id": "from_json",
"label": "Json",
"entities": [], "menu": [], "views": {}
}))
.unwrap(),
)
.unwrap();
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
assert_eq!(cards.len(), 1);
assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn load_cards_from_dir_propagates_per_file_errors_loud() {
let root = unique_dir("dir-error-loud");
let sub = root.join("broken");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap();
let err = brahman_cards::load_cards_from_dir(&root).unwrap_err();
assert!(
matches!(err, CardLoadError::JsonParse(_)),
"el error de un file roto debe propagar fail-loud, got {err:?}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn load_cards_from_dir_with_custom_filenames() {
let root = unique_dir("dir-custom-fname");
let sub = root.join("only");
std::fs::create_dir(&sub).unwrap();
// Filename custom que NO está en el default.
std::fs::write(
sub.join("manifest.json"),
serde_json::to_vec(&json!({
"id": "x",
"label": "X",
"entities": [], "menu": [], "views": {}
}))
.unwrap(),
)
.unwrap();
// Default no encuentra nada (skipea):
let with_default = brahman_cards::load_cards_from_dir(&root).unwrap();
assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json");
// Custom filename encuentra:
let with_custom = brahman_cards::load_cards_from_dir_with(
&root,
&["manifest.json"],
&brahman_cards::default_readers(),
)
.unwrap();
assert_eq!(with_custom.len(), 1);
assert_eq!(with_custom[0].id, "x");
std::fs::remove_dir_all(&root).ok();
}
// ===========================================================================
// Helpers de tests
// ===========================================================================
fn tempfile_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-test-{}-{}",
std::process::id(),
name
));
p
}
fn unique_dir(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-test-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
name
));
std::fs::create_dir_all(&p).unwrap();
p
}
@@ -0,0 +1,371 @@
//! Nickel reader + templates.
//!
//! V2 del brazo: la dispatcher acepta archivos `.ncl`. La evaluación
//! produce JSON intermedio que va a los readers estándar, así que un
//! `.ncl` puede generar cualquier `CardBody` siempre que su shape sea
//! reconocida.
//!
//! Templates: Nickel `import` + `&` merge nativos. El brazo no
//! inventa nada — sólo agrega el parent dir + el env
//! `BRAHMAN_CARDS_TEMPLATES_DIR` al import path.
use std::fs;
use std::path::PathBuf;
use brahman_cards::{
eval_nickel_file, load_card, CardBody, CardLoadError, NickelEvalError,
BRAHMAN_CARDS_TEMPLATES_ENV,
};
use serde_json::json;
// ===========================================================================
// Helpers
// ===========================================================================
fn unique_dir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-nickel-{}-{}-{}",
std::process::id(),
nanos(),
name
));
fs::create_dir_all(&p).unwrap();
p
}
fn nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).unwrap();
p
}
// ===========================================================================
// 1. Evaluación directa: Nickel → Value
// ===========================================================================
#[test]
fn eval_nickel_file_returns_value_for_valid_input() {
let dir = unique_dir("eval-basic");
let p = write_file(
&dir,
"card.ncl",
r#"
{
id = "demo",
label = "Demo",
entities = [],
menu = [],
views = {},
}
"#,
);
let v = eval_nickel_file(&p).expect("eval ok");
assert_eq!(v.get("id"), Some(&json!("demo")));
assert_eq!(v.get("label"), Some(&json!("Demo")));
assert!(v.get("entities").is_some());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn eval_nickel_file_surfaces_evaluation_error() {
let dir = unique_dir("eval-err");
let p = write_file(
&dir,
"broken.ncl",
r#"
{
id = "x",
label = doesnotexist,
}
"#,
);
let err = eval_nickel_file(&p).unwrap_err();
match err {
NickelEvalError::Eval { path, message } => {
assert!(path.contains("broken.ncl"));
assert!(!message.is_empty(), "el msg debe traer info de Nickel");
}
other => panic!("expected Eval error, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
// ===========================================================================
// 2. load_card pipeline: .ncl → Card
// ===========================================================================
#[test]
fn load_card_dispatches_ncl_to_ui_module_variant() {
let dir = unique_dir("dispatch-ui");
let p = write_file(
&dir,
"module.ncl",
r#"
{
id = "demo",
label = "Demo",
entities = [],
menu = [{ label = "Stock", view = "stock_list" }],
views = {
stock_list = {
kind = "list",
title = "Stock",
entity = "Stock",
columns = [],
},
},
}
"#,
);
let card = load_card(&p).expect("load ok");
assert_eq!(card.body.kind_name(), "ui_module");
assert_eq!(card.id, "demo");
assert_eq!(card.label, "Demo");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_card_dispatches_ncl_to_ente_variant() {
let dir = unique_dir("dispatch-ente");
let p = write_file(
&dir,
"ente.ncl",
r#"
{
schema_version = 1,
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
label = "test-ente",
payload = "Virtual",
supervision = "OneShot",
}
"#,
);
let card = load_card(&p).expect("load ok");
assert_eq!(card.body.kind_name(), "ente");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
fs::remove_dir_all(&dir).ok();
}
// ===========================================================================
// 3. Templates: import + merge native de Nickel
// ===========================================================================
/// El caso de uso que el usuario describió: "un Card simple usa un
/// Card ya hecho cambiando sólo nombre y id". Template define la
/// shape full; el archivo concreto importa + override.
#[test]
fn template_merge_overrides_id_and_label_only() {
let dir = unique_dir("template-merge");
// Template con la shape full de un UiModule. Los campos
// sobrescribibles se marcan `| default` — Nickel sólo permite
// override en merge cuando hay diferencia de prioridad. Sin
// `| default` los strings no-iguales fallan con "non mergeable".
write_file(
&dir,
"ui_module_basic.ncl",
r#"
{
id | String | default = "TEMPLATE_ID",
label | String | default = "TEMPLATE_LABEL",
description = "stock + form básico",
entities = [
{ name = "Item", label = "Item", fields = [] },
],
menu = [
{ label = "Items", view = "items_list" },
{ label = "+ Item", view = "items_form" },
],
views = {
items_list = {
kind = "list",
title = "Items",
entity = "Item",
columns = [],
},
items_form = {
kind = "form",
title = "Nuevo item",
entity = "Item",
fields = [],
on_submit = {
kind = "seed_entity",
entity = "Item",
next_view = "items_list",
},
},
},
}
"#,
);
// Card concreto: import + merge override.
let p = write_file(
&dir,
"my_module.ncl",
r#"
let base = import "ui_module_basic.ncl" in
base & {
id = "my_module",
label = "Mi Módulo",
}
"#,
);
let card = load_card(&p).expect("template merge ok");
assert_eq!(card.id, "my_module", "el override del id se aplicó");
assert_eq!(card.label, "Mi Módulo", "el override del label se aplicó");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
// El resto viene del template intacto.
assert_eq!(m.menu.len(), 2);
assert_eq!(m.entities.len(), 1);
assert_eq!(m.entities[0].name, "Item");
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
}
/// El env `BRAHMAN_CARDS_TEMPLATES_DIR` permite tener un registry
/// global: el usuario importa por nombre desnudo desde cualquier
/// ubicación.
///
/// Este test setea/unset el env de forma local (no thread-safe en
/// tests paralelos contra el mismo env, pero usamos una key dedicada
/// y borramos después). Si se vuelve flaky, agregar mutex.
#[test]
fn template_resolves_via_env_registry() {
let registry = unique_dir("template-registry");
let inputs = unique_dir("template-input");
write_file(
&registry,
"ui_module_minimal.ncl",
r#"
{
id | String | default = "X",
label | String | default = "X",
entities = [],
menu = [],
views = {},
}
"#,
);
let p = write_file(
&inputs,
"from_registry.ncl",
r#"
let base = import "ui_module_minimal.ncl" in
base & { id = "registry_user", label = "Usado del Registry" }
"#,
);
// Set env, evaluar, restaurar.
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
// SAFETY: nickel-lang tests modifican un env ad-hoc que no es
// referenciado por nada externo y se restaura al salir. Ningún
// otro test del crate lee este env.
unsafe {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &registry);
}
let result = load_card(&p);
unsafe {
if let Some(v) = prev {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v);
} else {
std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV);
}
}
let card = result.expect("template via registry ok");
assert_eq!(card.id, "registry_user");
assert_eq!(card.body.kind_name(), "ui_module");
fs::remove_dir_all(&registry).ok();
fs::remove_dir_all(&inputs).ok();
}
// ===========================================================================
// 4. Errores propagan limpios al CardLoadError
// ===========================================================================
#[test]
fn load_card_wraps_nickel_error_in_card_load_error() {
let dir = unique_dir("wrap-err");
let p = write_file(&dir, "bad.ncl", "let x = unknown in x");
let err = load_card(&p).unwrap_err();
match err {
CardLoadError::Nickel(NickelEvalError::Eval { .. }) => {} // expected
other => panic!("expected Nickel(Eval), got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
/// El value-add concreto de Nickel sobre JSON: un contract
/// violation se captura en evaluación, ANTES de que el reader
/// JSON tenga oportunidad de aceptar un shape mal-tipado. Acá un
/// `id | String` con un value que no es String falla en eval-time
/// con un mensaje legible. JSON puro lo aceptaría y rompería más
/// tarde aguas abajo.
#[test]
fn nickel_contract_violation_caught_at_eval_time() {
let dir = unique_dir("contract-violation");
let p = write_file(
&dir,
"bad_id.ncl",
r#"
{
id | String = 42,
label = "X",
entities = [],
menu = [],
views = {},
}
"#,
);
let err = load_card(&p).unwrap_err();
match err {
CardLoadError::Nickel(NickelEvalError::Eval { message, .. }) => {
// Mensaje de contract violation legible.
assert!(
message.contains("contract") || message.contains("String"),
"msg debe mencionar contract o String: {message}"
);
}
other => panic!("expected Nickel(Eval), got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
/// Sanity: un Nickel que evalúa a un shape NO-reconocible (no
/// matchea ningún reader) cae en `NoMatchingReader` — la cadena
/// Nickel + dispatcher se mantiene coherente.
#[test]
fn ncl_evaluating_to_unknown_shape_returns_no_matching_reader() {
let dir = unique_dir("unknown-shape");
let p = write_file(
&dir,
"weird.ncl",
r#"{ random = "shape", without = "fingerprint" }"#,
);
let err = load_card(&p).unwrap_err();
assert!(
matches!(err, CardLoadError::NoMatchingReader),
"expected NoMatchingReader, got {err:?}"
);
fs::remove_dir_all(&dir).ok();
}
@@ -0,0 +1,217 @@
//! Tests E2E de los templates canónicos shipped con el crate.
//!
//! Cada test escribe un Card user-side en un tempdir, importa el
//! template canónico, override id/label/etc., y verifica que el
//! brazo lo dispatcha al variant correcto del CardBody con los
//! valores merged.
//!
//! `BRAHMAN_CARDS_TEMPLATES_DIR` se setea localmente en cada test.
//! Como Nickel también busca relativo al input file, usamos el env
//! para que `import "ente_basic.ncl"` (sin path) resuelva desde
//! cualquier ubicación del input.
use std::fs;
use std::path::PathBuf;
use brahman_cards::{
canonical_templates_dir, load_card, CardBody, BRAHMAN_CARDS_TEMPLATES_ENV,
};
/// Helper: corre `f()` con `BRAHMAN_CARDS_TEMPLATES_ENV` set al
/// directorio de templates canónicos, restaurando el env al salir.
///
/// Tests no son thread-safe entre sí cuando comparten env. Por eso
/// quedan en serial via `nextest --test-threads=1` o `cargo test`
/// que paralelizara sólo entre `tests/*.rs` distintos. Como este
/// archivo encapsula todo el setup de env, aún en paralelo entre
/// archivos de tests no chocan (cada thread setea/restaura).
fn with_canonical_templates<F: FnOnce()>(f: F) {
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
let dir = canonical_templates_dir();
// SAFETY: env mutation single-threaded en este test.
unsafe {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &dir);
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prev {
Some(v) => std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v),
None => std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV),
}
}
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
fn unique_dir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-templates-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
name
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn ente_basic_template_overridden_loads_as_ente_card() {
with_canonical_templates(|| {
let dir = unique_dir("ente");
let card_path = dir.join("my_ente.ncl");
fs::write(
&card_path,
r#"
let base = import "ente_basic.ncl" in
base & {
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
label = "mi-ente",
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load ente");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert_eq!(card.label, "mi-ente");
assert_eq!(card.body.kind_name(), "ente");
match card.body {
CardBody::Ente(e) => {
assert_eq!(e.label, "mi-ente");
// Defaults del template intactos.
assert_eq!(e.schema_version, 1);
// Payload es el "Virtual" del template default.
assert!(
matches!(e.payload, brahman_card::Payload::Virtual),
"payload debería ser Virtual, got {:?}",
e.payload
);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn monad_basic_template_overridden_loads_as_monad_card() {
with_canonical_templates(|| {
let dir = unique_dir("monad");
let card_path = dir.join("my_monad.ncl");
fs::write(
&card_path,
r#"
let base = import "monad_basic.ncl" in
base & {
id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
label = "fotos-2026",
cardinality = 5,
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load monad");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAW");
assert_eq!(card.label, "fotos-2026");
assert_eq!(card.body.kind_name(), "monad");
match card.body {
CardBody::Monad(m) => {
assert_eq!(m.label, "fotos-2026");
assert_eq!(m.cardinality, 5);
// Defaults del template intactos.
assert_eq!(m.schema_version, 1);
assert!(m.members.is_empty());
assert!(m.summary.is_empty());
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn ui_module_basic_template_overridden_loads_as_ui_module_card() {
with_canonical_templates(|| {
let dir = unique_dir("ui");
let card_path = dir.join("my_module.ncl");
fs::write(
&card_path,
r#"
let base = import "ui_module_basic.ncl" in
base & {
id = "customers",
label = "Clientes",
menu = [{ label = "Lista", view = "list" }],
views = {
list = {
kind = "list",
title = "Customers",
entity = "Customer",
columns = [],
},
},
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load ui_module");
assert_eq!(card.id, "customers");
assert_eq!(card.label, "Clientes");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
assert_eq!(m.id, "customers");
assert_eq!(m.menu.len(), 1);
assert!(m.views.contains_key("list"));
// Defaults del template: entities vacío.
assert!(m.entities.is_empty());
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn template_default_id_and_label_pass_through_when_not_overridden() {
// Sanity: si el usuario importa el template SIN override de
// id/label, los defaults `"TEMPLATE_ID"` y `"TEMPLATE_LABEL"`
// pasan al wrapper Card.id/label. El brazo no falla — sólo
// los muestra como están. Validar este flow garantiza que un
// user "vacío" (importa y no override) carga sin error.
with_canonical_templates(|| {
let dir = unique_dir("defaults");
let card_path = dir.join("noop.ncl");
fs::write(&card_path, r#"import "ui_module_basic.ncl""#).unwrap();
let card = load_card(&card_path).expect("load defaults");
assert_eq!(card.id, "TEMPLATE_ID");
assert_eq!(card.label, "TEMPLATE_LABEL");
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn canonical_templates_dir_actually_exists() {
// Sanity: el path expuesto por canonical_templates_dir tiene
// que apuntar a un directorio que existe físicamente, sino los
// tests anteriores fallarían silenciosamente (Nickel reporta
// import-not-found pero el test ya estaría roto).
let d = canonical_templates_dir();
assert!(d.is_dir(), "templates dir no existe: {}", d.display());
for fname in ["ente_basic.ncl", "monad_basic.ncl", "ui_module_basic.ncl"] {
let p = d.join(fname);
assert!(p.is_file(), "template missing: {}", p.display());
}
}
@@ -0,0 +1,37 @@
[package]
name = "brahman-handshake"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (brahman-net)."
[dependencies]
brahman-card = { path = "../brahman-card" }
brahman-broker = { path = "../brahman-broker" }
brahman-net = { path = "../brahman-net" }
blake3 = { workspace = true }
futures = { workspace = true }
notify = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
[[example]]
name = "probe"
path = "examples/probe.rs"
[[example]]
name = "subscriber"
path = "examples/subscriber.rs"
@@ -0,0 +1,51 @@
//! probe — herramienta de diagnóstico del handshake.
//!
//! Conecta a un Init brahman vivo, hace handshake, un ping, y se va.
//! Ruta del socket: `$BRAHMAN_INIT_SOCKET` o el default
//! ([`brahman_handshake::transport::default_socket_path`]).
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example probe
//! ```
use std::collections::BTreeSet;
use brahman_card::{Card, Payload, Supervision, CARD_SCHEMA_VERSION};
use brahman_handshake::{client::Client, transport};
use ulid::Ulid;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: "brahman-probe".into(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
..Default::default()
};
let path = transport::default_socket_path();
println!("connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
let info = client.server_info();
println!(
" HelloAck: session={} server={} protocol={} init_attached={}",
client.session(),
info.server_version,
info.protocol_version,
info.init_attached
);
let ts = client.ping().await?;
println!(" Pong: ts={}ms", ts);
client.farewell().await?;
println!(" Farewell OK");
Ok(())
}
@@ -0,0 +1,83 @@
//! `subscriber` — cliente brahman que loguea cada `MatchEvent` recibido.
//!
//! Declara una Card con un input `in` de tipo `json`. Cada vez que el
//! broker matchea (o desmatch) ese input contra un productor, imprime
//! una línea. Útil para visualizar la dinámica del broker en vivo.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example subscriber [label]
//! ```
use std::collections::BTreeSet;
use std::time::Duration;
use brahman_card::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::{client::Client, transport};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let label = std::env::args()
.nth(1)
.unwrap_or_else(|| "subscriber".into());
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![],
},
..Default::default()
};
let path = transport::default_socket_path();
eprintln!("[{label}] connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
eprintln!(
"[{label}] attached: session={} init={}",
client.session(),
client.server_info().init_attached
);
// Loop: espera hasta 25s por un MatchEvent. Si timeout, ping para
// mantener la conexión viva.
loop {
match client.await_event(Duration::from_secs(25)).await? {
Some(ev) => {
eprintln!(
"[{label}] {:?} {}{}.{} via={:?}{}",
ev.kind,
ev.consumer_flow,
if ev.producer_label.is_empty() {
"<none>"
} else {
&ev.producer_label
},
ev.producer_flow,
ev.via,
if ev.pinned { " 📌" } else { "" }
);
}
None => {
let _ts = client.ping().await?;
}
}
}
}
@@ -0,0 +1,312 @@
//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
use std::collections::VecDeque;
use std::path::Path;
use std::time::Duration;
use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION};
use brahman_net::Keypair;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame};
use crate::identity::SessionCert;
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
use crate::signature::{sign_hello, SignatureError};
/// Errores del cliente.
#[derive(Debug, Error)]
pub enum ClientError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
/// El servidor respondió con un error explícito.
#[error("servidor: {0}")]
Server(#[source] HandshakeError),
/// El servidor envió un frame que no esperábamos en este punto del protocolo.
#[error("frame inesperado: {got}")]
UnexpectedFrame { got: &'static str },
/// La Card que el cliente intentó enviar no pasa su propia validación.
#[error("card inválida pre-envío: {0}")]
InvalidCard(String),
/// Firma del Hello falló al construirse (rara — sólo puede pasar
/// si la keypair pasada está en un estado inválido).
#[error("firma del Hello falló: {0}")]
Signature(#[from] SignatureError),
}
/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
/// request/response se buferean en `pending_events` y se obtienen vía
/// [`Client::take_event`] o [`Client::await_event`].
///
/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
/// `brahman_handshake::network`).
#[derive(Debug)]
pub struct Client<S = UnixStream> {
stream: S,
session: SessionId,
server_info: HelloAck,
pending_events: VecDeque<MatchEvent>,
}
impl Client<UnixStream> {
/// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
/// Equivalente a `connect_with(path, card, None)`.
pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
Self::connect_with(path, card, None).await
}
/// Conecta al socket Unix enviando Hello con la Card dada y
/// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
/// el server registra el módulo como "consciente".
pub async fn connect_with(
path: impl AsRef<Path>,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).await?;
Self::connect_with_stream(stream, card, wit).await
}
}
impl<S> Client<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
/// Constructor genérico sobre un stream ya abierto, **sin firma**.
/// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
/// o tests in-memory. Para libp2p remoto usá
/// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
/// el server libp2p rechaza Hello sin firma.
pub async fn connect_with_stream(
stream: S,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, None, None).await
}
/// Igual que `connect_with_stream` pero firma el Hello con
/// `keypair`. Usar para conexiones libp2p donde el server exige
/// firma. La public key derivada de `keypair` debe coincidir con
/// el `peer_id` libp2p autenticado por Noise — típicamente la
/// keypair pasada a [`brahman_net::BrahmanNet::with_keypair`].
pub async fn connect_with_stream_signed(
stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(keypair), None).await
}
/// Igual que `connect_with_stream_signed` pero además adjunta un
/// `SessionCert` que vincula la session keypair a una identity
/// master estable. El server, al recibir el cert, evalúa la
/// política de admisión contra el `master_peer_id` (no contra
/// el session peer_id) — permitiendo rotar la session sin perder
/// la identidad reconocida en allowlists remotas.
pub async fn connect_with_stream_signed_with_cert(
stream: S,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
identity_cert: SessionCert,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
}
async fn connect_inner(
mut stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: Option<&Keypair>,
identity_cert: Option<SessionCert>,
) -> Result<Self, ClientError> {
card.validate()
.map_err(|e| ClientError::InvalidCard(e.to_string()))?;
let wire_card = brahman_card::WireCard::from(card);
let signature = match keypair {
Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?),
None => None,
};
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
card: wire_card,
wit,
signature,
identity_cert,
};
write_frame(&mut stream, &Frame::Hello(hello)).await?;
let frame = read_frame(&mut stream).await?;
let ack = match frame {
Frame::HelloAck(a) => a,
Frame::Error(e) => return Err(ClientError::Server(e)),
Frame::Hello(_) => return Err(ClientError::UnexpectedFrame { got: "Hello" }),
Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
Frame::MatchEvent(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchEvent (pre-handshake)",
});
}
Frame::ListSessions(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListSessions (pre-handshake)",
});
}
Frame::SessionList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "SessionList (pre-handshake)",
});
}
Frame::ListMatches(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListMatches (pre-handshake)",
});
}
Frame::MatchList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchList (pre-handshake)",
});
}
};
Ok(Self {
stream,
session: ack.session,
server_info: ack,
pending_events: VecDeque::new(),
})
}
/// `SessionId` asignado por el servidor.
pub fn session(&self) -> SessionId {
self.session
}
/// Información del servidor recibida en el handshake.
pub fn server_info(&self) -> &HelloAck {
&self.server_info
}
/// Envía un Ping y devuelve el timestamp del servidor. Los frames
/// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
pub async fn ping(&mut self) -> Result<u64, ClientError> {
write_frame(
&mut self.stream,
&Frame::Ping(Ping {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::Pong(p) => return Ok(p.timestamp_ms),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
}
}
}
/// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
pub fn take_event(&mut self) -> Option<MatchEvent> {
self.pending_events.pop_front()
}
/// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
/// está vacío, lee del wire hasta el timeout. Otros frames recibidos
/// (Pong huérfano, Error) cortan la espera con error.
pub async fn await_event(
&mut self,
timeout: Duration,
) -> Result<Option<MatchEvent>, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(Some(ev));
}
match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
Err(_) => Ok(None),
Ok(Err(e)) => Err(ClientError::Io(e)),
Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
got: "non-event en await_event",
}),
}
}
/// Pide al servidor el listado de sesiones activas. Pensado para
/// observadores (broker-explorer, CLIs de diagnóstico). Como
/// `ping`, los `MatchEvent` que lleguen intercalados se bufean
/// en `pending_events` y no rompen la respuesta.
pub async fn list_sessions(&mut self) -> Result<crate::messages::SessionList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListSessions(crate::messages::ListSessions {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::SessionList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-session-list",
});
}
}
}
}
/// Pide al servidor el listado de matches actuales del broker
/// (consumer↔producer pares con tipo y estrategia). Mismo patrón
/// de drenado de `MatchEvent`s intermedios.
pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListMatches(crate::messages::ListMatches {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::MatchList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-match-list",
});
}
}
}
}
/// Cierre cooperativo. Consume el cliente.
pub async fn farewell(mut self) -> Result<(), ClientError> {
write_frame(
&mut self.stream,
&Frame::Farewell(Farewell {
session: self.session,
}),
)
.await?;
Ok(())
}
}
@@ -0,0 +1,72 @@
//! Codec de wire: frames length-prefixed con cuerpo postcard.
//!
//! Cada frame en el stream tiene la forma:
//! ```text
//! [4 bytes LE: longitud N] [N bytes: postcard(Frame)]
//! ```
//!
//! El `MAX_FRAME_BYTES` evita que un cliente malicioso/buggy reserve memoria
//! arbitraria al anunciar un length absurdo.
use std::io::{Error, ErrorKind, Result};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::messages::Frame;
/// Tamaño máximo de un frame antes de que el reader rechace la conexión.
/// 4 MiB cubre cualquier Card razonable con margen amplio.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Escribe un frame al stream.
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Frame) -> Result<()> {
let bytes = postcard::to_allocvec(frame)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard encode: {e}")))?;
if bytes.len() > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame demasiado grande: {} bytes", bytes.len()),
));
}
let len = bytes.len() as u32;
w.write_all(&len.to_le_bytes()).await?;
w.write_all(&bytes).await?;
w.flush().await?;
Ok(())
}
/// Lee un frame del stream.
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Frame> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf).await?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame anunciado demasiado grande: {len} bytes"),
));
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
postcard::from_bytes(&buf)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard decode: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::{Frame, HandshakeError};
#[tokio::test]
async fn frame_roundtrip() {
let frame = Frame::Error(HandshakeError::Rejected("test".into()));
let mut buf = Vec::new();
write_frame(&mut buf, &frame).await.unwrap();
let mut cursor = std::io::Cursor::new(buf);
let decoded = read_frame(&mut cursor).await.unwrap();
match decoded {
Frame::Error(HandshakeError::Rejected(s)) => assert_eq!(s, "test"),
_ => panic!("variant mismatch"),
}
}
}
@@ -0,0 +1,358 @@
//! Identidad multi-key del nodo: separación entre **identity** (master,
//! persistente forever) y **session** (keypair libp2p efímera, rotable).
//!
//! ## Problema que resuelve
//!
//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
//! keypair (por compromiso, por higiene, por cambio de hardware)
//! cambiaba el peer_id, lo que invalidaba todas las allowlists
//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
//! coordinar.
//!
//! ## Modelo
//!
//! Cada nodo tiene **dos** keypairs Ed25519:
//!
//! - **Identity** (master): persistente para siempre. Identifica al
//! nodo como entidad lógica. Su `peer_id` es lo que va en
//! allowlists/denylists remotas.
//! - **Session** (operacional): la que libp2p usa para Noise. Puede
//! rotarse libremente sin coordinar — el nodo emite un
//! [`SessionCert`] firmado con la identity que prueba "esta session
//! key pertenece a mí".
//!
//! ## Wire
//!
//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
//! server valida:
//! 1. La session key del cert == public key de `Hello.signature` ==
//! deriva al peer_id autenticado por Noise (consistencia interna).
//! 2. La firma del cert verifica con la master pubkey declarada.
//! 3. El cert no está expirado.
//! 4. La política (allowlist/denylist) se evalúa contra
//! `master.to_peer_id()`, NO contra el session peer_id.
//!
//! Sin cert, el server cae al modelo de Fase 3: policy contra session
//! peer_id (compat). Esto permite migración gradual.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use brahman_net::{Keypair, PeerId, PublicKey};
use serde::{Deserialize, Serialize};
/// TTL recomendado para un session cert: 24 horas. Suficiente para
/// que un nodo "viva" un día sin re-emitir; corto enough para que
/// un cert robado no sirva por mucho. Operadores con políticas
/// estrictas pueden bajarlo; con uptime largo, subirlo.
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Identidad lógica del nodo. Wraps la master keypair y emite certs
/// de session firmados.
///
/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
/// Sólo se usa para firmar certs locales y para derivar
/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
/// session keypair.
#[derive(Clone)]
pub struct Identity {
master: Arc<Keypair>,
}
impl Identity {
/// Construye una Identity a partir de una keypair existente.
/// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
pub fn from_keypair(master: Keypair) -> Self {
Self {
master: Arc::new(master),
}
}
/// Variante para callers que ya tienen la keypair en `Arc`.
pub fn from_arc(master: Arc<Keypair>) -> Self {
Self { master }
}
/// PeerId derivado de la master pubkey. Ésta es la identidad
/// "lógica" estable del nodo — lo que va en allowlists/denylists.
pub fn master_peer_id(&self) -> PeerId {
self.master.public().to_peer_id()
}
/// Emite un [`SessionCert`] firmado: certifica que la session
/// keypair `session` pertenece a esta identity hasta `now + ttl`.
pub fn issue_session_cert(
&self,
session: &Keypair,
ttl: Duration,
) -> Result<SessionCert, CertError> {
let now_ms = now_unix_ms();
let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
let session_pubkey = session.public().encode_protobuf();
let master_pubkey = self.master.public().encode_protobuf();
let payload = sign_payload(&session_pubkey, expires_at_ms);
let signature = self
.master
.sign(&payload)
.map_err(|e| CertError::Sign(e.to_string()))?;
Ok(SessionCert {
version: SESSION_CERT_VERSION,
session_pubkey,
master_pubkey,
expires_at_ms,
signature,
})
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("master_peer_id", &self.master_peer_id())
.finish()
}
}
/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
/// el shape de `SessionCert`.
pub const SESSION_CERT_VERSION: u8 = 1;
/// Certificado firmado por la identity que vincula una session key
/// libp2p a la identidad master del nodo, con expiración.
///
/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
/// formato canónico libp2p (`encode_protobuf`) — mismo encoding que
/// `HelloSignature.public_key`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionCert {
/// Versión del esquema (ver `SESSION_CERT_VERSION`).
pub version: u8,
/// Public key de la session libp2p (la que firma el Hello), en
/// formato libp2p protobuf.
pub session_pubkey: Vec<u8>,
/// Public key de la master identity, en formato libp2p protobuf.
/// El verificador deriva el `master_peer_id` desde acá.
pub master_pubkey: Vec<u8>,
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
/// cert no es válido y el nodo debe re-emitirse uno nuevo
/// (rotando o re-firmando la misma session).
pub expires_at_ms: u64,
/// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
pub signature: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum CertError {
#[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
UnknownVersion(u8),
#[error("decode master_pubkey: {0}")]
DecodeMaster(String),
#[error("decode session_pubkey: {0}")]
DecodeSession(String),
#[error("firma del cert inválida")]
InvalidSignature,
#[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
Expired { expires: u64, now: u64 },
#[error("session_pubkey del cert no coincide con la del Hello.signature")]
SessionMismatch,
#[error("error al firmar: {0}")]
Sign(String),
}
impl SessionCert {
/// Verifica el cert: versión, firma criptográfica, no expiración.
/// Devuelve el `(master_peer_id, session_peer_id)` derivados.
///
/// El caller debe además chequear que `session_peer_id` coincide
/// con el peer_id autenticado por Noise (lo verifica
/// [`verify_against_session`]).
pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
if self.version != SESSION_CERT_VERSION {
return Err(CertError::UnknownVersion(self.version));
}
let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
.map_err(|e| CertError::DecodeMaster(e.to_string()))?;
let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
.map_err(|e| CertError::DecodeSession(e.to_string()))?;
let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
if !master_pk.verify(&payload, &self.signature) {
return Err(CertError::InvalidSignature);
}
let now = now_unix_ms();
if now >= self.expires_at_ms {
return Err(CertError::Expired {
expires: self.expires_at_ms,
now,
});
}
Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
}
/// Verifica el cert Y exige que su `session_pubkey` matchee a
/// `expected_session_pubkey` (la que firmó el Hello). Esto
/// previene que un atacante reutilice un cert válido con una
/// session key distinta.
///
/// Devuelve el `master_peer_id` derivado, que es el que el server
/// debe usar para evaluar la política de admisión.
pub fn verify_against_session(
&self,
expected_session_pubkey: &[u8],
) -> Result<PeerId, CertError> {
if self.session_pubkey.as_slice() != expected_session_pubkey {
return Err(CertError::SessionMismatch);
}
let (master_peer, _session_peer) = self.verify()?;
Ok(master_peer)
}
}
/// Concat canónico de los campos firmados. Cualquier cambio aquí
/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
buf.push(SESSION_CERT_VERSION);
buf.extend_from_slice(b"sess");
buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
buf.extend_from_slice(session_pubkey);
buf.extend_from_slice(&expires_at_ms.to_le_bytes());
buf
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_and_verify_cert() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let (master_peer, session_peer) = cert.verify().unwrap();
assert_eq!(master_peer, id.master_peer_id());
assert_eq!(session_peer, session.public().to_peer_id());
}
#[test]
fn verify_against_session_admits_matching() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let session_pk = session.public().encode_protobuf();
let master_peer = cert.verify_against_session(&session_pk).unwrap();
assert_eq!(master_peer, id.master_peer_id());
}
#[test]
fn verify_against_session_rejects_mismatch() {
let master = Keypair::generate_ed25519();
let session_a = Keypair::generate_ed25519();
let session_b = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
.unwrap();
let other_pk = session_b.public().encode_protobuf();
let err = cert.verify_against_session(&other_pk).unwrap_err();
assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
}
#[test]
fn cert_with_zero_ttl_is_expired() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, Duration::from_secs(0))
.unwrap();
// Pequeña espera para asegurar que now_ms > expires_at_ms.
std::thread::sleep(Duration::from_millis(5));
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
}
#[test]
fn tampered_signature_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
if let Some(b) = cert.signature.last_mut() {
*b ^= 0x01;
}
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn tampered_expires_at_rejected() {
// Si alguien extiende el expires_at sin re-firmar, la firma
// no cuadra → InvalidSignature.
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn unknown_version_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.version = 99;
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
}
#[test]
fn rotated_session_with_same_master_yields_same_master_peer_id() {
// La propiedad fundamental: rotar la session key NO cambia el
// master_peer_id derivado del cert.
let master = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let original_master_peer = id.master_peer_id();
let session1 = Keypair::generate_ed25519();
let cert1 = id
.issue_session_cert(&session1, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert1, _) = cert1.verify().unwrap();
// Rotar: nueva session keypair, mismo master.
let session2 = Keypair::generate_ed25519();
let cert2 = id
.issue_session_cert(&session2, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert2, _) = cert2.verify().unwrap();
assert_eq!(master_from_cert1, original_master_peer);
assert_eq!(master_from_cert2, original_master_peer);
assert_eq!(
master_from_cert1, master_from_cert2,
"rotar session NO debe cambiar el master_peer_id"
);
}
}
@@ -0,0 +1,33 @@
//! `brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
//!
//! Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
//! lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
//! que son los módulos Brahman. Cada conexión arranca con un `Hello` que
//! lleva una [`brahman_card::Card`]; el servidor valida la Card, deriva el
//! [`TrustLevel`], emite un `HelloAck` con `session-id` ULID, y a partir de
//! ahí acepta `Ping`/`Farewell`.
//!
//! Wire format: frames length-prefixed (4 bytes LE) con cuerpo
//! [`postcard`]-codificado. Compacto, rápido y reversible.
//!
//! Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
//! implementación nativa Rust↔Rust que cubre el caso común antes de que los
//! módulos WASM consuman el mismo contrato vía ABI generada.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod codec;
pub mod identity;
pub mod messages;
pub mod server;
pub mod client;
pub mod network;
pub mod peer_policy;
pub mod signature;
pub mod transport;
pub use brahman_card::PROTOCOL_VERSION;
/// Versión del crate de handshake (independiente de `PROTOCOL_VERSION`).
pub const HANDSHAKE_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -0,0 +1,234 @@
//! Mensajes del protocolo de handshake.
//!
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
use std::path::PathBuf;
use brahman_broker::MatchStrategy;
use brahman_card::{TypeRef, WireCard, WitInterface};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
/// Identificador de sesión emitido por el servidor en `HelloAck`.
pub type SessionId = Ulid;
/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
/// la convierte a `Card` para uso interno. Opcionalmente, una
/// `WitInterface` ya extraída — si está presente, el módulo es
/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`.
///
/// **Firma (Fase 3, trust remoto)**: el campo `signature` es
/// obligatorio para conexiones libp2p (donde el server exige que la
/// public key derive al `peer_id` autenticado por Noise) y opcional
/// para Unix socket (donde SO_PEERCRED del kernel ya provee
/// autenticación). La firma cubre los bytes postcard de
/// `(WireCard, Option<WitInterface>)` — ver
/// [`HelloSignature::sign_payload`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// Versión del schema de Card que el cliente sigue.
pub schema_version: u16,
/// Versión del protocolo handshake del cliente.
pub protocol_version: String,
/// Tarjeta de Presentación, proyectada al wire.
pub card: WireCard,
/// Interfaz WIT extraída por el cliente (típicamente con
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
#[serde(default)]
pub wit: Option<WitInterface>,
/// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones
/// remotas (libp2p); opcional para Unix socket. Ver módulo
/// [`super::signature`] para construcción y verificación.
#[serde(default)]
pub signature: Option<HelloSignature>,
/// Cert opcional que vincula la session keypair (la que firma el
/// Hello) a una **identity master** estable. Si está presente,
/// la política de admisión se evalúa contra el `master_peer_id`
/// derivado del cert — no contra el session peer_id. Esto permite
/// rotar la session sin invalidar las allowlists remotas.
///
/// Ver [`super::identity::SessionCert`] para shape y semantics.
/// Si es `None`, fallback al modelo de Fase 3: la política
/// evalúa el session peer_id directamente.
#[serde(default)]
pub identity_cert: Option<crate::identity::SessionCert>,
}
/// Firma de un Hello. La `public_key` viaja en el formato canónico
/// libp2p (protobuf) — el verificador la decodifica y compara su
/// `peer_id` derivado con la identidad libp2p autenticada por Noise.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HelloSignature {
/// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`.
pub public_key: Vec<u8>,
/// Bytes de la firma Ed25519 sobre el payload canonical.
pub signature: Vec<u8>,
}
/// Respuesta del servidor a un `Hello` aceptado.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloAck {
/// Versión del crate del servidor.
pub server_version: String,
/// Versión del protocolo soportada por el servidor.
pub protocol_version: String,
/// Identificador de sesión asignado.
pub session: SessionId,
/// `true` si el Init está vinculado al servidor; `false` si el servidor
/// corre standalone (modo degradado).
pub init_attached: bool,
}
/// Latido del cliente. El servidor responde con [`Pong`] llevando su reloj.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
pub session: SessionId,
}
/// Respuesta a un `Ping` con timestamp del servidor (ms desde UNIX_EPOCH).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pong {
pub timestamp_ms: u64,
}
/// Cierre cooperativo de la sesión por parte del cliente.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Farewell {
pub session: SessionId,
}
/// Errores del protocolo emitidos por el servidor.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum HandshakeError {
#[error("protocolo incompatible: {0}")]
ProtocolMismatch(String),
#[error("card inválida: {0}")]
InvalidCard(String),
#[error("schema de card incompatible: cliente={client}, servidor={server}")]
SchemaMismatch { client: u16, server: u16 },
#[error("sin autorización: {0}")]
Unauthorized(String),
#[error("capacidad requerida no satisfecha: {0}")]
CapabilityUnmet(String),
#[error("rechazado: {0}")]
Rejected(String),
#[error("error interno: {0}")]
Internal(String),
}
/// Notificación push del server al consumer: un match disponible o perdido.
///
/// El server emite `Available` cuando un productor empieza a satisfacer un
/// `flow.input` del consumer (ya sea porque el productor acaba de
/// registrarse, o porque cambió el match anterior). Emite `Lost` cuando
/// el productor previo dejó de satisfacer el input (desregistro o
/// cambio de match).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchEvent {
pub kind: MatchEventKind,
/// Nombre del input del consumer al que aplica el evento.
pub consumer_flow: String,
/// Sesión y label del productor (en `Lost` puede ser nil/vacío).
pub producer_session: SessionId,
pub producer_label: String,
pub producer_flow: String,
/// Tipo del flujo matcheado.
pub ty: TypeRef,
/// Estrategia que ganó (relevante en `Available`).
pub via: MatchStrategy,
/// `true` si fue resuelto por `pin_to`.
pub pinned: bool,
/// Socket de servicio (data plane) que declaró el productor.
/// Si está presente, el consumer puede conectar directo sin
/// pasar por discovery adicional. `None` si el productor no
/// declaró service_socket en su Card.
#[serde(default)]
pub producer_service_socket: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MatchEventKind {
Available,
Lost,
}
/// Pedido de listado de sesiones activas registradas en el broker. La
/// `session` es el id propio del que pregunta — el server lo valida
/// contra la sesión actual de la conexión, mismo patrón que `Ping`.
///
/// Pensado para herramientas de observabilidad (broker-explorer y
/// CLIs de diagnóstico). No expone secrets: sólo metadata pública
/// que el módulo ya anunció en su `Hello`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSessions {
pub session: SessionId,
}
/// Una entrada en la respuesta a `ListSessions`. Slim por diseño —
/// el observer arma la UI con esto sin tener que abrir conexiones
/// adicionales por sesión.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub session: SessionId,
/// Label declarado en `WireCard.label` — el "nombre humano" del
/// módulo.
pub label: String,
/// Versión del schema de Card que el módulo declaró.
pub schema_version: u16,
/// Nombres de los `flow.output` que la Card declara producir.
pub outputs: Vec<String>,
/// Nombres de los `flow.input` que la Card declara consumir.
pub inputs: Vec<String>,
/// `true` si el módulo se anunció como "consciente" (trajo
/// `WitInterface` extraída en el Hello).
pub conscious: bool,
}
/// Respuesta a `ListSessions`. El orden no está garantizado — los
/// clientes que necesiten estabilidad pueden ordenar por `session`
/// (Ulid es ordenable temporal).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionList {
pub entries: Vec<SessionEntry>,
}
/// Pedido del listado de matches actuales del broker. La `session`
/// se valida igual que `ListSessions`. Si el server no tiene broker
/// configurado, devuelve la lista vacía (no es un error — refleja
/// que no hay matching activo).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMatches {
pub session: SessionId,
}
/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
/// actualmente computados por el broker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchList {
pub matches: Vec<brahman_broker::Match>,
}
/// Frame único de wire — discriminada por variante. Cada conexión es un
/// stream de frames.
///
/// Direcciones:
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
/// `ListMatches`.
/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
/// `SessionList`, `MatchList`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Frame {
Hello(Hello),
HelloAck(HelloAck),
Ping(Ping),
Pong(Pong),
Farewell(Farewell),
Error(HandshakeError),
MatchEvent(MatchEvent),
ListSessions(ListSessions),
SessionList(SessionList),
ListMatches(ListMatches),
MatchList(MatchList),
}
@@ -0,0 +1,289 @@
//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
//! length-prefixed) ahora también viaja sobre streams libp2p de la
//! malla `brahman-net`.
//!
//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
//! mismo `Server` que ya escucha por Unix socket — la `Session` es
//! genérica sobre el transporte, así que ambas vías comparten broker,
//! tablas de sesiones, push de MatchEvents, todo.
//!
//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
//! cualquier `Client`.
//!
//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
//! Hello, como en el path Unix. Trust remoto (verificación de firma
//! antes de aceptar el Hello) es Fase 3.
//!
//! Ejemplo (servidor — Arje):
//! ```ignore
//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
//! let net = Arc::new(BrahmanNet::new()?);
//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
//!
//! tokio::spawn(brahman_handshake::network::run_libp2p_accept_loop(
//! server.clone(),
//! net.clone(),
//! ));
//! // Server::run sigue escuchando Unix en paralelo.
//! ```
//!
//! Ejemplo (cliente — sidecar de un Ente remoto):
//! ```ignore
//! let net = BrahmanNet::new()?;
//! net.dial(remote_multiaddr);
//! let mut client = brahman_handshake::network::connect_libp2p(
//! &net, peer_id, my_card, None,
//! ).await?;
//! client.ping().await?;
//! ```
use std::sync::Arc;
use brahman_card::{Card, TypeRef, WitInterface};
use brahman_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
use crate::identity::SessionCert;
use futures::StreamExt;
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
use tracing::{debug, warn};
use crate::client::{Client, ClientError};
use crate::server::Server;
/// Sub-protocolo del handshake brahman sobre streams libp2p.
pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
StreamProtocol::new("/brahman/handshake/1.0.0");
/// Tipo del stream que ve la lógica del handshake una vez convertido
/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
/// `tokio::io::AsyncRead/Write` (resto del crate).
pub type LibP2pHandshakeStream = Compat<Stream>;
/// Errores específicos del backend libp2p.
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("abrir stream libp2p: {0}")]
OpenStream(#[from] OpenStreamError),
#[error("handshake: {0}")]
Handshake(#[from] ClientError),
#[error("aceptar stream libp2p: {0}")]
AcceptStream(String),
}
/// Loop de aceptación de streams libp2p del protocolo handshake.
/// Cada stream entrante se construye como `Session` reutilizando las
/// tablas compartidas del `Server`, así que conviven con sesiones
/// Unix indistinguibles.
///
/// Vive hasta que el control libp2p se cierre o el caller drop el
/// future. Errores por sesión se loggean (no tumban el loop).
pub async fn run_libp2p_accept_loop(
server: Arc<Server>,
net: Arc<BrahmanNet>,
) -> Result<(), NetworkError> {
let mut control = net.control.clone();
let mut incoming = control
.accept(BRAHMAN_HANDSHAKE_PROTOCOL)
.map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
while let Some((peer, stream)) = incoming.next().await {
let server = server.clone();
// .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
// y capturamos `Compat<Stream>` en la closure, el future
// resultante hereda traits que dyn AsyncReadWrite no satisface
// (compatibility con thread-safety de tokio::spawn).
tokio::spawn(handle_libp2p_session(server, stream, peer));
}
Ok(())
}
async fn handle_libp2p_session(
server: Arc<Server>,
stream: Stream,
peer: PeerId,
) {
// session_from_libp2p_stream propaga el peer_id al `do_handshake`,
// que exige firma del Hello cuya public key derive a este peer.
let session = server.session_from_libp2p_stream(stream.compat(), peer);
if let Err(e) = session.handle().await {
warn!(
target: "brahman_handshake::network",
peer = %peer,
error = %e,
"sesión libp2p terminó con error"
);
}
}
/// Conecta como cliente a un Ente remoto vía libp2p y completa el
/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga
/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace
/// `net.dial(multiaddr)` antes.
///
/// La `keypair` debe ser la misma que la del nodo libp2p (la que
/// pasaste a [`brahman_net::BrahmanNet::with_keypair`]). Si no coincide
/// con el `peer_id` autenticado por Noise, el server rechaza el Hello
/// con `Unauthorized`.
///
/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
/// `farewell` funcionan idéntico al path Unix. El stream subyacente
/// es libp2p convertido vía `tokio_util::compat`.
pub async fn connect_libp2p(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?;
Ok(client)
}
/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
/// El server, al verificar el cert, evalúa la política de admisión
/// contra el `master_peer_id` derivado — no contra el `peer_id`
/// libp2p. Esto permite **rotar** la session keypair sin perder
/// reconocimiento en allowlists remotas.
///
/// El `keypair` debe ser la session libp2p (la que firma la conexión
/// Noise); el `cert` debe haber sido emitido por una identity master
/// para esa misma session pubkey (ver
/// [`crate::identity::Identity::issue_session_cert`]).
pub async fn connect_libp2p_with_cert(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
cert: SessionCert,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed_with_cert(
stream.compat(),
card,
wit,
session_keypair,
cert,
)
.await?;
Ok(client)
}
// =====================================================================
// Discovery remoto via DHT — Fase 2
// =====================================================================
//
// Cuando un Ente registra una Card con outputs en el Init local, el
// Init anuncia al DHT (`net.start_providing(key)`) bajo una key
// derivada de `(flow_name, TypeRef)`. Cualquier nodo conectado al
// mismo DHT puede consultar `find_remote_providers(flow_name, type)`
// y obtener la lista de `PeerId`s que dijeron proveer ese flow.
//
// La key es **estable y libre de colisiones** entre versiones del
// monorepo: usa blake3 sobre un canon textual `brahman-flow|{name}|{type_canon}`.
// Cambiar la canonicalización rompe el discovery cross-version, así
// que cualquier modificación requiere bump de versión documentado.
/// Prefijo de namespace para todas las keys DHT del subprotocolo
/// brahman. Discrimina contra otros usos del mismo DHT (sync minga,
/// futuros) — protege contra colisiones accidentales.
const FLOW_KEY_PREFIX: &str = "brahman-flow|v1|";
/// Canonicaliza un `TypeRef` a string estable. Cambios aquí rompen
/// la compatibilidad de discovery cross-version; bump documentado
/// en `FLOW_KEY_PREFIX` al modificar.
fn canonicalize_type(t: &TypeRef) -> String {
match t {
TypeRef::Primitive { name } => format!("prim:{}", name),
TypeRef::Wit {
package,
interface,
name,
} => format!(
"wit:{}#{}#{}",
package,
interface.as_deref().unwrap_or(""),
name
),
}
}
/// Deriva la key del DHT para un `(flow_name, type_ref)` específico.
/// blake3-32B determinístico — la misma tupla en cualquier máquina
/// produce la misma key.
pub fn flow_dht_key(flow_name: &str, type_ref: &TypeRef) -> [u8; 32] {
let canon = format!(
"{}{}|{}",
FLOW_KEY_PREFIX,
flow_name,
canonicalize_type(type_ref)
);
*blake3::hash(canon.as_bytes()).as_bytes()
}
/// Anuncia al DHT que este nodo provee cada output flow declarado
/// en `card`. Llamarlo tras `register_session` propaga la
/// disponibilidad a todos los peers que comparten DHT con éste.
///
/// Idempotente: re-anunciar la misma key actualiza el TTL del record
/// en el DHT. Best-effort: si `start_providing` falla por falta de
/// peers cercanos (DHT vacío), el record vive en el store local
/// hasta que llegue una conexión.
pub fn announce_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "brahman_handshake::network",
flow = %flow.name,
"announce_output → DHT"
);
net.start_providing(&key);
}
}
/// Retira los anuncios DHT previos de [`announce_outputs`] para esta
/// `card`. Llamado desde `cleanup` cuando una sesión cierra (Farewell,
/// EOF, error). El record local se borra al instante; copias
/// replicadas en peers remotos expiran por TTL natural de kad.
pub fn withdraw_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "brahman_handshake::network",
flow = %flow.name,
"withdraw_output → DHT (stop_providing)"
);
net.stop_providing(&key);
}
}
/// Consulta el DHT por peers que han anunciado proveer el flow
/// `(flow_name, type_ref)`. Devuelve la lista resuelta de `PeerId`s.
/// Lista vacía si nadie anuncia, si la query timeout-ea, o si el
/// DHT no ha encontrado providers.
///
/// Para cada `PeerId` devuelto, el caller puede luego dial-ar al
/// peer (a sus addrs conocidas vía Identify) y abrir un sub-handshake
/// remoto con [`connect_libp2p`].
pub async fn find_remote_providers(
net: &BrahmanNet,
flow_name: &str,
type_ref: &TypeRef,
) -> Vec<PeerId> {
let key = flow_dht_key(flow_name, type_ref);
net.find_providers(&key).await
}
@@ -0,0 +1,582 @@
//! Política de admisión de peers libp2p: allowlist + denylist con hot
//! reload opcional.
//!
//! Capa de política sobre el trust criptográfico de Fase 3. Combina:
//!
//! - **Denylist**: peers explícitamente baneados. Si está, deny gana.
//! - **Allowlist**: si está set, sólo los peers listados pasan.
//! Si no está set, modo abierto (todo peer Ed25519-válido pasa,
//! sujeto sólo a denylist).
//!
//! Sin denylist y sin allowlist → modo totalmente abierto (compat
//! con todo lo anterior). Con allowlist y denylist a la vez, el
//! orden de evaluación es: deny first → allow check → admit.
//!
//! Aplica únicamente al path libp2p — el path Unix usa SO_PEERCRED
//! del kernel para autenticación local, no PeerId.
//!
//! ## Hot reload
//!
//! Si la política se construyó con [`PeerPolicy::watch_files`], un
//! thread dedicado vigila los archivos de allow/deny vía `notify`.
//! Cualquier cambio (write, create, modify, remove) dispara una
//! recarga atómica con debounce de 250ms (los editores típicos
//! producen varios eventos por save).
//!
//! Errores de reload (parse fallido, archivo eliminado) se loggean
//! pero NO bajan la política existente — aceptamos la versión
//! anterior hasta que el archivo vuelva a parsearse limpio. Esto
//! evita que un error de tipeo deje al Init en modo inseguro.
//!
//! ## Formato del archivo
//!
//! Idéntico para allow y deny: PeerId base58 por línea, `#` para
//! comentarios (línea entera o inline), líneas vacías ignoradas.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use brahman_net::{BrahmanNet, PeerId};
use tracing::{debug, info, warn};
/// Política de admisión combinada (allow + deny). Clone barato (todos
/// los campos son Arc o referencias inmutables).
#[derive(Clone)]
pub struct PeerPolicy {
inner: Arc<RwLock<PolicyInner>>,
paths: Arc<PolicyPaths>,
/// `BrahmanNet` opcional asociado vía [`Self::attach_to_net`].
/// Si está set, cada cambio en la denylist se sincroniza con el
/// `block_list` behaviour del swarm — los peers baneados son
/// rechazados ANTES del Noise handshake. `RwLock<Option<...>>`
/// para que `attach_to_net` se pueda llamar después del
/// constructor (típico en ente-zero: primero arma la policy,
/// después el net, después attach).
net: Arc<RwLock<Option<Arc<BrahmanNet>>>>,
}
#[derive(Default)]
struct PolicyInner {
/// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto.
allow: Option<BTreeSet<PeerId>>,
/// Peers baneados. Vacío = sin denylist.
deny: BTreeSet<PeerId>,
}
#[derive(Default)]
struct PolicyPaths {
allow_path: Option<PathBuf>,
deny_path: Option<PathBuf>,
}
/// Decisión del gate de política para un peer dado.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// El peer es admitido (no está en deny y, si hay allow, está en allow).
Admit,
/// El peer está explícitamente en la denylist.
DeniedByDenylist,
/// Hay allowlist configurada y el peer no está en ella.
NotInAllowlist,
}
impl Decision {
pub fn is_admitted(self) -> bool {
matches!(self, Decision::Admit)
}
pub fn reason(self) -> &'static str {
match self {
Decision::Admit => "admit",
Decision::DeniedByDenylist => "explicitly denied",
Decision::NotInAllowlist => "not in allowlist",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("leer política en {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("línea {line_no} de {path}: PeerId inválido '{value}'")]
InvalidPeerId {
path: PathBuf,
line_no: usize,
value: String,
},
}
impl PeerPolicy {
/// Política totalmente abierta: todo peer pasa. Útil como default
/// cuando no hay archivos configurados.
pub fn open() -> Self {
Self {
inner: Arc::new(RwLock::new(PolicyInner::default())),
paths: Arc::new(PolicyPaths::default()),
net: Arc::new(RwLock::new(None)),
}
}
/// Construye una política inline con sets explícitos. Sin paths
/// asociados, así que `reload` y `watch_files` son no-ops.
pub fn from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>) -> Self {
Self {
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
paths: Arc::new(PolicyPaths::default()),
net: Arc::new(RwLock::new(None)),
}
}
/// Carga política desde archivos. Cada path es opcional: `None`
/// significa "esa lista no aplica" (allow=None ⇒ modo abierto;
/// deny=None ⇒ sin baneados). Asocia los paths internamente para
/// que `reload` y `watch_files` los re-lean.
pub fn from_files(
allow_path: Option<&Path>,
deny_path: Option<&Path>,
) -> Result<Self, PolicyError> {
let allow = match allow_path {
Some(p) => Some(parse_peer_set(p)?),
None => None,
};
let deny = match deny_path {
Some(p) => parse_peer_set(p)?,
None => BTreeSet::new(),
};
Ok(Self {
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
paths: Arc::new(PolicyPaths {
allow_path: allow_path.map(Path::to_path_buf),
deny_path: deny_path.map(Path::to_path_buf),
}),
net: Arc::new(RwLock::new(None)),
})
}
/// Evalúa si `peer` puede registrarse. Toma read lock — barato,
/// concurrente, sin awaits.
pub fn evaluate(&self, peer: &PeerId) -> Decision {
let inner = match self.inner.read() {
Ok(g) => g,
Err(_) => {
// Lock envenenado: degrada a "deny por seguridad".
warn!("policy lock envenenado — deny por defecto");
return Decision::DeniedByDenylist;
}
};
if inner.deny.contains(peer) {
return Decision::DeniedByDenylist;
}
if let Some(allow) = &inner.allow {
if !allow.contains(peer) {
return Decision::NotInAllowlist;
}
}
Decision::Admit
}
/// Tamaño actual de cada lista, para logging. Tupla `(allow_count,
/// deny_count)`. `allow_count = None` significa "modo abierto"
/// (sin allowlist).
pub fn sizes(&self) -> (Option<usize>, usize) {
match self.inner.read() {
Ok(g) => (g.allow.as_ref().map(|s| s.len()), g.deny.len()),
Err(_) => (Some(0), 0),
}
}
/// Recarga atómica desde los paths asociados. Si un archivo
/// falla, la versión anterior persiste y el error se devuelve.
/// Esto evita que un typo en el archivo deje al Init en modo
/// inseguro.
///
/// Si hay un `BrahmanNet` attached vía [`Self::attach_to_net`],
/// el cambio de denylist se sincroniza con el `block_list` del
/// swarm: se calcula el diff (added/removed) y se aplican
/// `block_peer`/`unblock_peer` por cada cambio.
pub fn reload(&self) -> Result<(), PolicyError> {
let new_allow = match &self.paths.allow_path {
Some(p) => Some(parse_peer_set(p)?),
None => None,
};
let new_deny = match &self.paths.deny_path {
Some(p) => parse_peer_set(p)?,
None => BTreeSet::new(),
};
// Snapshot de la deny actual ANTES de mutar, para diff.
let prev_deny = self
.inner
.read()
.map(|g| g.deny.clone())
.unwrap_or_default();
if let Ok(mut inner) = self.inner.write() {
inner.allow = new_allow;
inner.deny = new_deny.clone();
}
self.sync_deny_to_swarm(&prev_deny, &new_deny);
Ok(())
}
/// Asocia esta política a un `BrahmanNet`. Sincroniza el snapshot
/// actual de la denylist con el `block_list` behaviour del swarm
/// (cada peer baneado se rechaza ANTES del Noise handshake), y
/// registra el net para re-sincronizarse en cada [`Self::reload`].
///
/// Si ya había un net attached, se reemplaza (caso esperado:
/// un Init no debería tener dos `BrahmanNet`s).
pub fn attach_to_net(&self, net: Arc<BrahmanNet>) {
// Sync inicial: bloquear todos los peers actualmente en deny.
if let Ok(inner) = self.inner.read() {
for peer in &inner.deny {
net.block_peer(*peer);
}
}
if let Ok(mut slot) = self.net.write() {
*slot = Some(net);
}
}
/// Calcula el diff entre `prev` y `new` y aplica
/// `block_peer`/`unblock_peer` al net asociado (si hay).
/// No-op si no hay net attached.
fn sync_deny_to_swarm(&self, prev: &BTreeSet<PeerId>, new: &BTreeSet<PeerId>) {
let net = match self.net.read() {
Ok(g) => match g.as_ref() {
Some(n) => n.clone(),
None => return,
},
Err(_) => return,
};
for added in new.difference(prev) {
net.block_peer(*added);
}
for removed in prev.difference(new) {
net.unblock_peer(*removed);
}
}
/// Arranca un thread que vigila los archivos asociados con
/// `notify` y llama [`Self::reload`] cuando cambian. Debounce
/// 250ms para coalescer múltiples eventos por save (los editores
/// hacen Create+Modify+más).
///
/// Devuelve un `JoinHandle` que el caller debe mantener vivo.
/// Drop del handle no detiene el thread (notify watcher es
/// sticky); para detener, terminar el proceso.
///
/// No-op si no hay paths asociados (devuelve un handle dummy
/// que termina inmediatamente).
pub fn spawn_watcher(&self) -> std::io::Result<std::thread::JoinHandle<()>> {
let allow_path = self.paths.allow_path.clone();
let deny_path = self.paths.deny_path.clone();
let policy = self.clone();
if allow_path.is_none() && deny_path.is_none() {
// Sin archivos a vigilar: spawn un thread que termina ya.
return std::thread::Builder::new()
.name("brahman-policy-watcher-noop".into())
.spawn(|| {});
}
std::thread::Builder::new()
.name("brahman-policy-watcher".into())
.spawn(move || {
run_watcher(policy, allow_path, deny_path);
})
}
}
impl std::fmt::Debug for PeerPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (allow, deny) = self.sizes();
f.debug_struct("PeerPolicy")
.field("allow", &allow)
.field("deny", &deny)
.field("allow_path", &self.paths.allow_path)
.field("deny_path", &self.paths.deny_path)
.finish()
}
}
fn parse_peer_set(path: &Path) -> Result<BTreeSet<PeerId>, PolicyError> {
let contents = std::fs::read_to_string(path).map_err(|e| PolicyError::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut out = BTreeSet::new();
for (idx, raw) in contents.lines().enumerate() {
let line_no = idx + 1;
let trimmed = raw.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
continue;
}
let peer = trimmed
.parse::<PeerId>()
.map_err(|_| PolicyError::InvalidPeerId {
path: path.to_path_buf(),
line_no,
value: trimmed.to_string(),
})?;
out.insert(peer);
}
Ok(out)
}
const DEBOUNCE_MS: u64 = 250;
fn run_watcher(
policy: PeerPolicy,
allow_path: Option<PathBuf>,
deny_path: Option<PathBuf>,
) {
use notify::{RecursiveMode, Watcher};
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = match notify::recommended_watcher(move |res| {
let _ = tx.send(res);
}) {
Ok(w) => w,
Err(e) => {
warn!(?e, "notify watcher para policy no se pudo crear — hot reload deshabilitado");
return;
}
};
// Vigilamos los DIRECTORIOS de los archivos, no los archivos
// directos. Los editores típicos hacen rename-and-replace (escriben
// a tmp, rename al destino), lo que rompe el watch del archivo
// pero NO el del directorio. Trade-off: recibimos más eventos
// (cualquier archivo del dir), filtramos por path al procesar.
for p in [&allow_path, &deny_path].iter().filter_map(|x| x.as_ref()) {
if let Some(parent) = p.parent() {
if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) {
warn!(path = %parent.display(), ?e, "watch failed");
}
}
}
let debounce = Duration::from_millis(DEBOUNCE_MS);
let mut pending_at: Option<Instant> = None;
loop {
let timeout = match pending_at {
Some(at) => debounce.saturating_sub(at.elapsed()).max(Duration::from_millis(10)),
None => Duration::from_secs(60), // wakeup periódico, no esencial
};
match rx.recv_timeout(timeout) {
Ok(Ok(event)) => {
// Sólo nos interesan eventos sobre los paths exactos.
let touches_us = event.paths.iter().any(|p| {
Some(p) == allow_path.as_ref() || Some(p) == deny_path.as_ref()
});
if !touches_us {
continue;
}
debug!(?event.kind, "policy file event recibido — debounce");
pending_at = Some(Instant::now());
}
Ok(Err(e)) => {
warn!(?e, "notify error en policy watcher");
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if let Some(at) = pending_at {
if at.elapsed() >= debounce {
match policy.reload() {
Ok(()) => {
let (a, d) = policy.sizes();
info!(
allow = ?a,
deny = d,
"policy hot-reload completo"
);
}
Err(e) => {
warn!(?e, "policy hot-reload falló — manteniendo versión anterior");
}
}
pending_at = None;
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
warn!("policy watcher channel cerrado — terminando thread");
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brahman_net::Keypair;
use tempfile::TempDir;
fn fresh_peer() -> PeerId {
Keypair::generate_ed25519().public().to_peer_id()
}
#[test]
fn open_admits_anyone() {
let p = PeerPolicy::open();
assert_eq!(p.evaluate(&fresh_peer()), Decision::Admit);
}
#[test]
fn allow_only_admits_listed() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let policy = PeerPolicy::from_sets(
Some([p1].into_iter().collect()),
BTreeSet::new(),
);
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
}
#[test]
fn deny_overrides_open() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let policy = PeerPolicy::from_sets(None, [p1].into_iter().collect());
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn deny_overrides_allow() {
// Conflicto explícito: p1 está en ambas. Deny gana.
let p1 = fresh_peer();
let policy = PeerPolicy::from_sets(
Some([p1].into_iter().collect()),
[p1].into_iter().collect(),
);
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
}
#[test]
fn from_files_with_both_lists() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let p3 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
let deny = tmp.path().join("deny.txt");
std::fs::write(&allow, format!("{}\n{}\n", p1, p2)).unwrap();
std::fs::write(&deny, format!("# baneado\n{}\n", p2)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), Some(&deny)).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::DeniedByDenylist); // deny gana
assert_eq!(policy.evaluate(&p3), Decision::NotInAllowlist);
}
#[test]
fn from_files_only_deny() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let deny = tmp.path().join("deny.txt");
std::fs::write(&deny, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(None, Some(&deny)).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn reload_picks_up_changes() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
// Mutar el archivo: ahora p2 está, p1 no.
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
policy.reload().unwrap();
assert_eq!(policy.evaluate(&p1), Decision::NotInAllowlist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn reload_failure_preserves_previous_state() {
let p1 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
// Romper el archivo con basura.
std::fs::write(&allow, "this-is-not-a-peer-id\n").unwrap();
let err = policy.reload();
assert!(err.is_err(), "reload con typo debe fallar");
// Estado anterior se mantiene.
assert_eq!(
policy.evaluate(&p1),
Decision::Admit,
"policy debe conservar la versión anterior tras fallo de reload"
);
}
#[test]
fn invalid_file_rejected_at_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.txt");
std::fs::write(&path, "not-a-peer-id\n").unwrap();
let err = PeerPolicy::from_files(Some(&path), None).unwrap_err();
assert!(matches!(err, PolicyError::InvalidPeerId { .. }));
}
#[test]
fn watcher_reloads_on_file_change() {
// Test integración del watcher: arma policy con file, spawn
// watcher, modifica el archivo, espera el debounce, verifica
// que la policy refleja el cambio.
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
let _watcher = policy.spawn_watcher().unwrap();
// Le damos un instante al watcher para subscribirse al dir.
std::thread::sleep(Duration::from_millis(100));
// Mutamos el archivo: p2 reemplaza a p1.
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
// Esperamos > debounce + margen.
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if policy.evaluate(&p2) == Decision::Admit {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(
policy.evaluate(&p2),
Decision::Admit,
"watcher debería haber recargado la policy"
);
assert_eq!(
policy.evaluate(&p1),
Decision::NotInAllowlist,
"p1 debería haber salido tras el reload"
);
}
}
@@ -0,0 +1,855 @@
//! Servidor de handshake. Listener Unix socket → sesiones por conexión.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use brahman_broker::{Broker, Endpoint};
use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
use brahman_net::{BrahmanNet, PeerId};
use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, warn};
use ulid::Ulid;
use crate::codec::{read_frame, write_frame};
use crate::messages::{
Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, MatchEventKind, Ping, Pong,
SessionId,
};
/// Tabla de sesiones vivas indexada por `SessionId`.
pub type SessionRegistry = Arc<Mutex<HashMap<SessionId, ResolvedCard>>>;
/// Broker compartido (opcional) que el servidor mantiene en sincronía con
/// el ciclo de vida de las sesiones.
pub type SharedBroker = Arc<Mutex<Broker>>;
/// Tabla de canales push por sesión: el server inyecta frames hacia el
/// cliente (p. ej. `MatchEvent`) sin requerir que el cliente haga request.
type SessionTxTable = Arc<Mutex<HashMap<SessionId, mpsc::Sender<Frame>>>>;
/// Por sesión, último match conocido por nombre de input. Se usa para
/// emitir diffs (Available/Lost) en lugar del estado completo.
type LastMatches = Arc<Mutex<HashMap<SessionId, HashMap<String, Endpoint>>>>;
/// Capacidad del canal push por sesión. Si se llena (cliente lento), los
/// eventos extra se descartan — el cliente puede re-consultar el estado.
const PUSH_CHANNEL_CAPACITY: usize = 32;
/// Configuración del servidor.
#[derive(Clone, Default)]
pub struct ServerConfig {
/// `true` si el Init está atado al servidor (se reporta en `HelloAck`).
pub init_attached: bool,
/// Broker compartido. Si está presente, el servidor llama
/// `register` tras un Hello aceptado y `unregister` al cerrar la
/// sesión (Farewell o EOF). Si es `None`, el broker no se usa.
pub broker: Option<SharedBroker>,
/// Capa P2P compartida. Si está presente, cada Card registrada
/// con outputs se anuncia automáticamente al DHT vía
/// [`brahman_handshake::network::announce_outputs`], permitiendo
/// que un consumer remoto los descubra con
/// [`brahman_handshake::network::find_remote_providers`]. Si es
/// `None`, el server queda "ciego al DHT" — sólo matchea sesiones
/// locales (lo cual es correcto cuando no hay conectividad o no
/// se desea exponer al exterior).
pub net: Option<Arc<BrahmanNet>>,
/// Política de admisión de peers libp2p (allow + deny + hot
/// reload opcional). Si está presente, el trust gate del path
/// libp2p evalúa cada `peer_id` (ya autenticado por Noise)
/// contra esta política. `None` → modo totalmente abierto
/// (cualquier peer Ed25519-válido pasa). El path Unix la ignora.
pub policy: Option<crate::peer_policy::PeerPolicy>,
}
// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm
// no es Debug). Sólo loggeamos los campos relevantes para tracing.
impl std::fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerConfig")
.field("init_attached", &self.init_attached)
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
.field("net", &self.net.as_ref().map(|_| "<net>"))
.field("policy", &self.policy.as_ref().map(|p| p.sizes()))
.finish()
}
}
/// Servidor de handshake escuchando en un Unix socket.
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
}
impl Server {
/// Crea el listener en `path`. Si el archivo existe, lo elimina (asume
/// que es un socket stale de una sesión previa).
pub fn bind(path: impl Into<PathBuf>, config: ServerConfig) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
sessions: Arc::new(Mutex::new(HashMap::new())),
push_table: Arc::new(Mutex::new(HashMap::new())),
last_matches: Arc::new(Mutex::new(HashMap::new())),
config,
})
}
/// Devuelve la ruta del socket (útil para clientes en el mismo proceso).
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Vista compartida del registro de sesiones — útil para el Init/Admin
/// para inspeccionar quién está conectado.
pub fn sessions(&self) -> SessionRegistry {
self.sessions.clone()
}
/// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
/// No corre el handler — eso es responsabilidad del llamante.
/// Path Unix → `expected_peer = None` (firma del Hello opcional;
/// SO_PEERCRED del kernel ya autentica al cliente).
pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
let (stream, _addr) = self.listener.accept().await?;
Ok(self.session_from_stream(stream))
}
/// Construye una `Session` a partir de un stream arbitrario que
/// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path
/// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer`
/// queda en `None`, así que la firma del Hello es opcional.
pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, None)
}
/// Variante para conexiones libp2p: el `peer_id` viene autenticado
/// por Noise. La sesión exige firma del Hello cuya public key
/// derive a este `peer_id` exacto. Ver
/// [`super::network::run_libp2p_accept_loop`].
pub fn session_from_libp2p_stream<S>(
&self,
stream: S,
peer: PeerId,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, Some(peer))
}
fn session_from_stream_inner<S>(
&self,
stream: S,
expected_peer: Option<PeerId>,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
Session {
stream,
sessions: self.sessions.clone(),
push_table: self.push_table.clone(),
last_matches: self.last_matches.clone(),
config: self.config.clone(),
expected_peer,
}
}
/// Loop de aceptación: cada conexión se despacha en una task separada.
/// Vive hasta que el listener falle o el caller drop el future.
pub async fn run(self) -> std::io::Result<()> {
loop {
let session = self.accept_one().await?;
tokio::spawn(async move {
if let Err(e) = session.handle().await {
warn!(error = %e, "session terminó con error");
}
});
}
}
}
impl Drop for Server {
fn drop(&mut self) {
// Limpieza best-effort del socket. Si falla, log y seguir.
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar socket");
}
}
}
}
/// Conexión individual aceptada por el servidor. Genérica sobre el
/// transport — funciona indistinguiblemente sobre `UnixStream` (modo
/// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
/// duplex (tests), etc.
pub struct Session<S> {
stream: S,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
/// Si está set, el path es libp2p y `do_handshake` exige firma
/// del Hello cuya public key derive a este `peer_id`. Si es
/// `None`, el path es Unix/in-memory y la firma es opcional
/// (pero si está, se verifica anyway por defensa en profundidad).
expected_peer: Option<PeerId>,
}
impl<S> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
/// Procesa la conexión hasta `Farewell` o EOF.
///
/// Estructura: handshake (sobre el stream entero) → split en
/// halves (read + write) → reader loop principal + writer task
/// que drena el push channel. Garantiza cleanup (sessions + broker
/// + canales) sin importar la rama de salida.
///
/// El split es necesario para soportar streams `!Sync` como
/// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
/// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
/// su propia task, eliminando el requirement y permitiendo que
/// la misma `Session` sirva indistinta sobre Unix socket o
/// stream libp2p remoto.
pub async fn handle(self) -> std::io::Result<()> {
let Self {
mut stream,
sessions,
push_table,
last_matches,
config,
expected_peer,
} = self;
let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await?
{
Some(id) => id,
None => return Ok(()), // Hello rechazado, no se registró nada
};
let result = run_post_handshake(
stream,
session_id,
sessions.clone(),
push_table.clone(),
last_matches.clone(),
config.clone(),
)
.await;
cleanup(
session_id,
&sessions,
&push_table,
&last_matches,
&config,
)
.await;
result
}
}
// ============================================================================
// Free functions (post-refactor): la lógica del post-handshake corre sobre
// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
// ============================================================================
async fn run_post_handshake<S>(
stream: S,
session_id: SessionId,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// Canal por donde el server inyecta frames push (MatchEvent, etc.).
let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
push_table.lock().await.insert(session_id, tx);
// Tras registrar el canal, recomputar matches y emitir diffs.
broadcast_match_diffs(&push_table, &last_matches, &config).await;
// Split: reader en el hilo principal, writer compartido bajo Mutex
// entre la writer task (push channel) y el handler de inbound
// (que escribe Pong/Error). Mutex serializa writes; es OK porque
// la frecuencia de writes por sesión es baja.
let (mut reader, writer) = split(stream);
let writer = Arc::new(Mutex::new(writer));
// Writer task: drena el push channel.
let writer_for_push = writer.clone();
let writer_task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let mut w = writer_for_push.lock().await;
if write_frame(&mut *w, &frame).await.is_err() {
break;
}
}
});
// Reader loop principal.
let broker_for_loop = config.broker.clone();
let result: std::io::Result<()> = loop {
match read_frame(&mut reader).await {
Ok(frame) => {
match handle_inbound_frame(
session_id,
frame,
&writer,
&sessions,
broker_for_loop.as_ref(),
)
.await
{
Ok(true) => continue,
Ok(false) => break Ok(()),
Err(e) => break Err(e),
}
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(session = %session_id, "cliente cerró sin Farewell");
break Ok(());
}
Err(e) => break Err(e),
}
};
// Cerrar writer: drop nuestro Arc y abortar la task. La task
// saldrá igual cuando rx se cierre por drop del último Sender,
// pero abortarla es más rápido que esperar a que próximo recv()
// observe el cierre.
drop(writer);
writer_task.abort();
let _ = writer_task.await;
result
}
async fn handle_inbound_frame<S>(
session_id: SessionId,
frame: Frame,
writer: &Arc<Mutex<WriteHalf<S>>>,
sessions: &SessionRegistry,
broker_for_match: Option<&SharedBroker>,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
match frame {
Frame::Ping(Ping { session }) if session == session_id => {
let pong = Pong {
timestamp_ms: now_ms(),
};
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::Pong(pong)).await?;
Ok(true)
}
Frame::Ping(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
Frame::Farewell(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListSessions(crate::messages::ListSessions { session })
if session == session_id =>
{
let list = build_session_list(sessions).await;
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::SessionList(list)).await?;
Ok(true)
}
Frame::ListSessions(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListMatches(crate::messages::ListMatches { session })
if session == session_id =>
{
let matches = match &broker_for_match {
Some(b) => b.lock().await.all_matches(),
None => Vec::new(),
};
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::MatchList(crate::messages::MatchList { matches }),
)
.await?;
Ok(true)
}
Frame::ListMatches(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
_ => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Rejected(
"frame inesperado tras handshake".into(),
)),
)
.await?;
Ok(true)
}
}
}
/// Snapshot read-only de la `SessionRegistry` proyectado a la forma
/// de wire para el frame `SessionList`. Suelta el lock antes de
/// retornar para que el writer del frame no contenga el mutex.
async fn build_session_list(sessions: &SessionRegistry) -> crate::messages::SessionList {
let table = sessions.lock().await;
let entries = table
.iter()
.map(|(id, resolved)| crate::messages::SessionEntry {
session: *id,
label: resolved.card.label.clone(),
schema_version: resolved.card.schema_version,
outputs: resolved
.card
.flow
.output
.iter()
.map(|f| f.name.clone())
.collect(),
inputs: resolved
.card
.flow
.input
.iter()
.map(|f| f.name.clone())
.collect(),
conscious: resolved.wit.is_some(),
})
.collect();
crate::messages::SessionList { entries }
}
/// Limpieza atómica de las vistas registradas + (si net activo) retiro
/// de anuncios DHT de los outputs de la Card. Se ejecuta tanto si la
/// sesión cierra por Farewell, EOF, o error. Tras desregistrar, emite
/// diffs a las sesiones que perdieron el match contra ésta.
async fn cleanup(
session_id: SessionId,
sessions: &SessionRegistry,
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
// Tomamos la Card ANTES de borrarla — si net está configurado
// necesitamos sus outputs para llamar withdraw_outputs. `remove`
// devuelve el valor extraído.
let removed_card = sessions.lock().await.remove(&session_id);
push_table.lock().await.remove(&session_id);
last_matches.lock().await.remove(&session_id);
if let Some(broker) = &config.broker {
broker.lock().await.unregister(session_id);
}
if let (Some(net), Some(resolved)) = (&config.net, removed_card) {
crate::network::withdraw_outputs(net, &resolved.card);
}
broadcast_match_diffs(push_table, last_matches, config).await;
}
/// Recomputa los matches para todas las sesiones registradas y empuja
/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
/// respecto al último estado conocido.
async fn broadcast_match_diffs(
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
let broker = match &config.broker {
Some(b) => b,
None => return,
};
let b = broker.lock().await;
let push_table = push_table.lock().await;
let mut last = last_matches.lock().await;
debug!(
target: "brahman_handshake::broadcast",
cards = b.len(),
push_subscribers = push_table.len(),
"broadcast_match_diffs"
);
let cards: Vec<_> = b.cards().cloned().collect();
for cons in &cards {
let cons_session = cons.session;
let tx = match push_table.get(&cons_session) {
Some(tx) => tx,
None => continue,
};
let cons_last = last.entry(cons_session).or_default();
for input in &cons.inputs {
let new_match = b.find_producer_for(cons_session, &input.name);
let new_endpoint = new_match.as_ref().map(|m| m.producer.clone());
let old_endpoint = cons_last.get(&input.name).cloned();
if old_endpoint == new_endpoint {
continue;
}
if let Some(m) = &new_match {
let producer_service_socket = b
.cards()
.find(|c| c.session == m.producer.session)
.and_then(|c| c.service_socket.clone());
let event = MatchEvent {
kind: MatchEventKind::Available,
consumer_flow: input.name.clone(),
producer_session: m.producer.session,
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
ty: m.ty.clone(),
via: m.via,
pinned: m.pinned,
producer_service_socket,
};
let send_res = tx.try_send(Frame::MatchEvent(event));
debug!(
target: "brahman_handshake::broadcast",
consumer = %cons_session,
flow = %input.name,
producer = %m.producer_label,
result = ?send_res.as_ref().map(|_| "ok").unwrap_or_else(|e| match e {
tokio::sync::mpsc::error::TrySendError::Full(_) => "full",
tokio::sync::mpsc::error::TrySendError::Closed(_) => "closed",
}),
"Available pushed"
);
} else {
let event = MatchEvent {
kind: MatchEventKind::Lost,
consumer_flow: input.name.clone(),
producer_session: Ulid::nil(),
producer_label: String::new(),
producer_flow: String::new(),
ty: input.ty.clone(),
via: brahman_broker::MatchStrategy::Exact,
pinned: false,
producer_service_socket: None,
};
let _ = tx.try_send(Frame::MatchEvent(event));
}
if let Some(ep) = new_endpoint {
cons_last.insert(input.name.clone(), ep);
} else {
cons_last.remove(&input.name);
}
}
}
}
/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la
/// sesión y emite HelloAck.
async fn do_handshake<S>(
stream: &mut S,
config: &ServerConfig,
sessions: &SessionRegistry,
expected_peer: Option<PeerId>,
) -> std::io::Result<Option<SessionId>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let frame = read_frame(stream).await?;
let hello = match frame {
Frame::Hello(h) => h,
_ => {
write_frame(
stream,
&Frame::Error(HandshakeError::Rejected(
"primer frame debe ser Hello".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Some(err) = validate_hello(&hello) {
write_frame(stream, &Frame::Error(err)).await?;
return Ok(None);
}
// Identity cert (multi-key identity, opcional): si el cliente
// adjuntó cert, la "identidad lógica" del peer es el master
// derivado del cert (estable across rotaciones), no el session
// peer_id (efímero). Sin cert, fallback al modelo de Fase 3
// (logical = session). Esto permite migración gradual y
// backwards compatibility con clientes que no usan identity.
let logical_peer = if let (Some(session_peer), Some(cert)) =
(expected_peer, &hello.identity_cert)
{
let session_pk_bytes: &[u8] = match &hello.signature {
Some(sig) => &sig.public_key,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello con identity_cert requiere también signature".into(),
)),
)
.await?;
return Ok(None);
}
};
match cert.verify_against_session(session_pk_bytes) {
Ok(master_peer) => {
debug!(
session = %session_peer,
master = %master_peer,
"identity cert válido — policy se evalúa contra master_peer"
);
Some(master_peer)
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"identity cert inválido: {e}"
))),
)
.await?;
debug!(peer = %session_peer, error = %e, "cert rechazado");
return Ok(None);
}
}
} else {
expected_peer
};
// Policy gate (path libp2p): si está configurada, el peer
// autenticado debe pasar la política (deny first, luego allow).
// El peer evaluado es `logical_peer`: master si hay cert,
// session si no. Se chequea ANTES de la firma porque es
// comparación O(log n) sin crypto. La política no se aplica
// al path Unix (autenticación por SO_PEERCRED, no por PeerId).
if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
let decision = policy.evaluate(&peer);
if !decision.is_admitted() {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"peer {peer}: {}",
decision.reason()
))),
)
.await?;
debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
return Ok(None);
}
}
// Trust gate: en path libp2p (expected_peer = Some), exigir
// firma cuya public key derive al peer autenticado por Noise.
// En path Unix (expected_peer = None), si la firma viene se
// verifica anyway por defensa en profundidad — no es un error
// que esté ahí, pero si está debe ser válida.
if let Some(peer) = expected_peer {
let sig = match &hello.signature {
Some(s) => s,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello sin firma en conexión remota libp2p".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))),
)
.await?;
debug!(peer = %peer, error = %e, "firma rechazada");
return Ok(None);
}
} else if let Some(sig) = &hello.signature {
// Firma presente en path local: no exigida pero verificada.
// Si está y no valida, es un signo de Hello mal-construido y
// rechazamos por seguridad.
// Para Unix no tenemos peer_id contra el cual comparar; se
// verifica sólo la consistencia interna (firma sobre payload
// con la public_key declarada).
match brahman_net::PublicKey::try_decode_protobuf(&sig.public_key) {
Ok(pk) => {
let payload = match postcard::to_allocvec(&(
crate::signature::SIGNATURE_VERSION,
&hello.card,
&hello.wit,
)) {
Ok(b) => b,
Err(_) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Internal(
"no pude codificar payload para verificar firma".into(),
)),
)
.await?;
return Ok(None);
}
};
if !pk.verify(&payload, &sig.signature) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"firma del Hello presente pero inválida".into(),
)),
)
.await?;
return Ok(None);
}
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"public_key inválida en firma: {e}"
))),
)
.await?;
return Ok(None);
}
}
}
let session_id = Ulid::new();
let card: Card = hello.card.into();
register_session(session_id, card, hello.wit, config, sessions).await;
let ack = HelloAck {
server_version: crate::HANDSHAKE_VERSION.to_string(),
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
session: session_id,
init_attached: config.init_attached,
};
write_frame(stream, &Frame::HelloAck(ack)).await?;
debug!(session = %session_id, "handshake completado");
Ok(Some(session_id))
}
async fn register_session(
session_id: SessionId,
card: Card,
wit: Option<WitInterface>,
config: &ServerConfig,
sessions: &SessionRegistry,
) {
if let Some(broker) = &config.broker {
broker
.lock()
.await
.register(session_id, &card, wit.clone());
}
// Si el server tiene net configurado, anunciar los outputs al
// DHT para que peers remotos puedan descubrirlos. Idempotente
// y best-effort — fallos de Kad no propagan al handshake.
if let Some(net) = &config.net {
crate::network::announce_outputs(net, &card);
}
let resolved = match wit {
Some(w) => ResolvedCard::from_conscious(card, w),
None => ResolvedCard::from_agnostic(card),
};
sessions.lock().await.insert(session_id, resolved);
}
fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
if hello.schema_version != CARD_SCHEMA_VERSION {
return Some(HandshakeError::SchemaMismatch {
client: hello.schema_version,
server: CARD_SCHEMA_VERSION,
});
}
if hello.protocol_version != brahman_card::PROTOCOL_VERSION {
return Some(HandshakeError::ProtocolMismatch(format!(
"cliente={}, servidor={}",
hello.protocol_version,
brahman_card::PROTOCOL_VERSION
)));
}
let as_card: Card = Card::from(hello.card.clone());
if let Err(e) = as_card.validate() {
return Some(HandshakeError::InvalidCard(e.to_string()));
}
None
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
@@ -0,0 +1,155 @@
//! Firma y verificación del payload del `Hello` para trust remoto.
//!
//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer
//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica
//! del Ente a la identidad de transporte: si Noise autenticó al
//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión.
//!
//! ## Payload firmado
//!
//! Bytes postcard de la tupla `(WireCard, Option<WitInterface>)`. Se
//! eligió postcard porque ya es el wire format del resto del protocolo:
//! mismo determinismo, sin convertir a otro formato sólo para firmar.
//!
//! Cualquier campo que entre al payload firmado en el futuro debe
//! añadirse al final de la tupla (postcard es position-dependent), o
//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas.
use brahman_card::{WireCard, WitInterface};
use brahman_net::{Keypair, PeerId, PublicKey};
use crate::messages::HelloSignature;
/// Versión del esquema de payload firmado. Si cambia el shape de
/// `(WireCard, Option<WitInterface>)` o cómo se serializa, bump este
/// número y el verificador rechaza firmas antiguas.
pub const SIGNATURE_VERSION: u8 = 1;
/// Errores de verificación de firma.
#[derive(Debug, thiserror::Error)]
pub enum SignatureError {
#[error("public_key inválida (libp2p decode protobuf): {0}")]
DecodeKey(String),
#[error("encode del payload falló: {0}")]
EncodePayload(String),
#[error("firma rechazada: bytes inválidos para la public_key")]
Invalid,
#[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")]
PeerMismatch { signer: PeerId, expected: PeerId },
#[error("firma del Hello faltante (requerida para conexión remota libp2p)")]
Missing,
#[error("firma del Hello inesperada en path local sin trust remoto")]
Unexpected,
}
/// Construye los bytes canónicos a firmar/verificar para un Hello.
/// Postcard determinístico de `(version, WireCard, Option<WitInterface>)`.
fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, SignatureError> {
let tup = (SIGNATURE_VERSION, card, wit);
postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string()))
}
/// Firma `(card, wit)` con la `keypair`. La public key derivada de
/// `keypair` debe coincidir con la identidad libp2p del peer cuando
/// el verificador la chequee.
pub fn sign_hello(
keypair: &Keypair,
card: &WireCard,
wit: &Option<WitInterface>,
) -> Result<HelloSignature, SignatureError> {
let bytes = payload_bytes(card, wit)?;
let signature_bytes = keypair
.sign(&bytes)
.map_err(|e| SignatureError::EncodePayload(e.to_string()))?;
Ok(HelloSignature {
public_key: keypair.public().encode_protobuf(),
signature: signature_bytes,
})
}
/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que
/// la public key declarada coincide con `expected_peer` (la identidad
/// libp2p autenticada por Noise).
///
/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto.
pub fn verify_hello(
sig: &HelloSignature,
card: &WireCard,
wit: &Option<WitInterface>,
expected_peer: PeerId,
) -> Result<(), SignatureError> {
let public_key = PublicKey::try_decode_protobuf(&sig.public_key)
.map_err(|e| SignatureError::DecodeKey(e.to_string()))?;
let signer_peer = public_key.to_peer_id();
if signer_peer != expected_peer {
return Err(SignatureError::PeerMismatch {
signer: signer_peer,
expected: expected_peer,
});
}
let bytes = payload_bytes(card, wit)?;
if !public_key.verify(&bytes, &sig.signature) {
return Err(SignatureError::Invalid);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use brahman_card::Card;
fn sample_card() -> WireCard {
Card::new("test.signed").into()
}
#[test]
fn sign_then_verify_roundtrip() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar");
}
#[test]
fn verify_rejects_wrong_peer() {
let kp = Keypair::generate_ed25519();
let other = Keypair::generate_ed25519().public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
let err = verify_hello(&sig, &card, &wit, other).unwrap_err();
assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}");
}
#[test]
fn verify_rejects_tampered_card() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let original = sample_card();
let wit = None;
let sig = sign_hello(&kp, &original, &wit).unwrap();
// Verificamos contra una Card distinta (mismo shape, distinto label).
let tampered: WireCard = Card::new("test.tampered").into();
let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
#[test]
fn verify_rejects_corrupted_signature() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let mut sig = sign_hello(&kp, &card, &wit).unwrap();
// Flip un bit de la firma.
if let Some(b) = sig.signature.last_mut() {
*b ^= 0x01;
}
let err = verify_hello(&sig, &card, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
}
@@ -0,0 +1,52 @@
//! Convenciones de transporte: dónde vive el socket del Init.
//!
//! Resolución del path canónico:
//! 1. Variable de entorno [`SOCKET_ENV`] si está definida (override
//! explícito, prioridad máxima).
//! 2. `$XDG_RUNTIME_DIR/brahman-init.sock` (sesión usuario).
//! 3. `$TMPDIR/brahman-init.sock` (fallback portable).
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket del Init.
pub const SOCKET_ENV: &str = "BRAHMAN_INIT_SOCKET";
/// Nombre del socket dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-init.sock";
/// Ruta canónica al socket del Init brahman.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_override_wins() {
// Nota: estos tests modifican entorno del proceso. `cargo test`
// los corre paralelos por defecto pero usamos un nombre de var
// único y restablecemos al final.
let key = "BRAHMAN_INIT_SOCKET_TEST_OVERRIDE";
// SAFETY: sólo escribimos una variable local al test; sin
// contaminar SOCKET_ENV.
std::env::set_var(key, "/tmp/explicit.sock");
let saved = std::env::var(SOCKET_ENV).ok();
std::env::set_var(SOCKET_ENV, "/tmp/explicit.sock");
let p = default_socket_path();
assert_eq!(p, PathBuf::from("/tmp/explicit.sock"));
// Restaurar
match saved {
Some(v) => std::env::set_var(SOCKET_ENV, v),
None => std::env::remove_var(SOCKET_ENV),
}
std::env::remove_var(key);
}
}
@@ -0,0 +1,480 @@
//! Tests de integración: levanta server + client en el mismo proceso,
//! ejercita el round-trip completo del protocolo.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
Card, CgroupSpec, Flow, Flows, NamespaceSet, Payload, ResourceLimits, SomaSpec, Supervision,
TypeRef, CARD_SCHEMA_VERSION,
};
use brahman_handshake::{
client::{Client, ClientError},
codec::{read_frame, write_frame},
messages::{Frame, HandshakeError, Hello, Ping},
server::{Server, ServerConfig},
};
use tokio::net::UnixStream;
use tokio::sync::Mutex;
use ulid::Ulid;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
..Default::default()
}
}
/// Genera una ruta de socket única bajo TMPDIR. No la creamos —
/// el server la creará al hacer bind.
fn sock_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"brahman-test-{}-{}-{}.sock",
name,
std::process::id(),
Ulid::new()
))
}
#[tokio::test]
async fn full_handshake_roundtrip() {
let path = sock_path("happy");
let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, policy: None }).unwrap();
let session_handle = tokio::spawn({
async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
}
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
assert!(client.server_info().init_attached);
assert_eq!(
client.server_info().protocol_version,
brahman_card::PROTOCOL_VERSION
);
let mut last = 0u64;
for _ in 0..3 {
let ts = client.ping().await.unwrap();
assert!(ts >= last);
last = ts;
tokio::time::sleep(Duration::from_millis(2)).await;
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after farewell")
.unwrap();
}
#[tokio::test]
async fn list_sessions_returns_currently_registered() {
// Levantamos un server con broker (requerido para que el registro
// pase por el path real) y conectamos 3 clientes. El último pide
// ListSessions y debe ver a los 2 anteriores + a sí mismo.
let path = sock_path("listsess");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: true,
broker: Some(broker),
net: None,
policy: None,
},
)
.unwrap();
// Una task accept loop genérica para los 3 clientes.
let server_handle = tokio::spawn(async move {
for _ in 0..3 {
let session = server.accept_one().await.unwrap();
tokio::spawn(async move {
let _ = session.handle().await;
});
}
// Mantener el server vivo para que las sesiones puedan
// mantenerse abiertas mientras el observer pregunta.
std::future::pending::<()>().await;
});
let mut alpha = Client::connect(&path, sample_card("producer-alpha"))
.await
.unwrap();
let mut beta = Client::connect(&path, sample_card("producer-beta"))
.await
.unwrap();
// observer es el que va a preguntar.
let mut observer = Client::connect(&path, sample_card("observer"))
.await
.unwrap();
let list = observer.list_sessions().await.unwrap();
assert_eq!(list.entries.len(), 3, "deberían verse 3 sesiones activas");
let labels: BTreeSet<&str> = list.entries.iter().map(|e| e.label.as_str()).collect();
assert!(labels.contains("producer-alpha"));
assert!(labels.contains("producer-beta"));
assert!(labels.contains("observer"));
// schema_version + conscious sanity en la propia entry del observer.
let me = list
.entries
.iter()
.find(|e| e.label == "observer")
.unwrap();
assert_eq!(me.schema_version, brahman_card::CARD_SCHEMA_VERSION);
assert!(!me.conscious, "observer no envió WIT — debería ser agnostic");
alpha.farewell().await.unwrap();
beta.farewell().await.unwrap();
observer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn rejects_invalid_card_client_side() {
let path = sock_path("invalid");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
// No esperamos que el server complete: el cliente corta antes.
let _ = tokio::time::timeout(Duration::from_secs(1), async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
})
.await;
});
let mut bad = sample_card("placeholder");
bad.label = String::new();
let err = Client::connect(&path, bad).await.unwrap_err();
assert!(matches!(err, ClientError::InvalidCard(_)));
session_handle.abort();
}
#[tokio::test]
async fn server_rejects_protocol_mismatch() {
let path = sock_path("mismatch");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut stream = UnixStream::connect(&path).await.unwrap();
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: "999.0.0".into(),
card: sample_card("future-module").into(),
wit: None,
signature: None,
identity_cert: None,
};
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::ProtocolMismatch(_)) => {}
other => panic!("esperado ProtocolMismatch, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
// =====================================================================
// Integración handshake ↔ broker
// =====================================================================
fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
flow: Flows { input, output },
..Default::default()
}
}
fn flow(name: &str, ty: TypeRef) -> Flow {
Flow {
name: name.into(),
ty,
pin_to: None,
}
}
/// Espera hasta que `broker.len() >= n` o timeout.
async fn wait_for_broker_len(broker: &Arc<Mutex<Broker>>, n: usize) {
for _ in 0..50 {
if broker.lock().await.len() >= n {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("broker no alcanzó {n} entradas en 500ms");
}
#[tokio::test]
async fn broker_registers_and_unregisters_with_session() {
let path = sock_path("broker-lifecycle");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
let session_id = client.session();
// Tras el handshake, la Card debe estar registrada en el broker.
wait_for_broker_len(&broker, 1).await;
{
let b = broker.lock().await;
assert_eq!(b.len(), 1);
assert!(b.sessions().any(|s| s == session_id));
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server colgó tras farewell")
.unwrap();
// Tras el cleanup, el broker queda vacío.
{
let b = broker.lock().await;
assert_eq!(b.len(), 0);
}
}
#[tokio::test]
async fn broker_matches_two_live_modules() {
let path = sock_path("broker-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
// Server loop: usa la API run() para manejar accept+spawn.
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// Productor: emite "out" tipo string.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "string".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
wait_for_broker_len(&broker, 1).await;
// Consumidor: pide "in" tipo string.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "string".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
wait_for_broker_len(&broker, 2).await;
// El broker debe encontrar el match consumer.in ← producer.out.
let m = {
let b = broker.lock().await;
b.find_producer_for(consumer.session(), "in")
}
.expect("broker no encontró match");
assert_eq!(m.consumer_label, "ui");
assert_eq!(m.producer_label, "dht");
assert_eq!(m.producer.flow_name, "out");
// Cuando el productor se va, el match desaparece.
producer.farewell().await.unwrap();
for _ in 0..50 {
if broker.lock().await.len() < 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
{
let b = broker.lock().await;
assert!(b.find_producer_for(consumer.session(), "in").is_none());
}
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn match_event_pushed_on_producer_arrival() {
use brahman_handshake::messages::MatchEventKind;
let path = sock_path("push-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// El consumidor llega primero — sin productor, no hay match aún.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "json".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
// No debería haber evento todavía.
let no_event = consumer
.await_event(Duration::from_millis(100))
.await
.unwrap();
assert!(no_event.is_none(), "evento inesperado: {no_event:?}");
// Llega el productor → consumer debe recibir Available.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "json".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Available no llegó");
assert_eq!(ev.kind, MatchEventKind::Available);
assert_eq!(ev.consumer_flow, "in");
assert_eq!(ev.producer_label, "dht");
assert_eq!(ev.producer_flow, "out");
// El productor se va → consumer debe recibir Lost.
producer.farewell().await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Lost no llegó");
assert_eq!(ev.kind, MatchEventKind::Lost);
assert_eq!(ev.consumer_flow, "in");
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn ping_before_hello_rejected() {
let path = sock_path("ping-no-hello");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
// Conectamos y mandamos un Ping sin haber saludado.
let mut stream = UnixStream::connect(&path).await.unwrap();
write_frame(
&mut stream,
&Frame::Ping(Ping {
session: Ulid::new(),
}),
)
.await
.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::Rejected(_)) => {}
other => panic!("esperado Rejected, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
@@ -0,0 +1,340 @@
//! Test E2E de Fase 2: discovery remoto vía DHT.
//!
//! Pipeline:
//! 1. **Provider node (A)**: arma server con `BrahmanNet` configurado;
//! listen TCP; un cliente local registra una Card con un output
//! flow. El server llama `announce_outputs` automáticamente, lo
//! que hace `start_providing` en el DHT bajo la key derivada del
//! flow.
//! 2. **Consumer node (B)**: arma su propio `BrahmanNet`; dial-ea al
//! multiaddr del provider para que ambos se conozcan vía Identify
//! (esto popula sus respectivos routing tables de Kademlia).
//! 3. **B llama `find_remote_providers(flow_name, type)`**: la query
//! DHT propaga vía Kad, y eventually el provider responde con su
//! `PeerId`.
//! 4. **Verificación**: el `PeerId` que B descubre coincide con el
//! de A.
//!
//! Notas:
//! - Kademlia replication factor por defecto es 20; con 2 nodos no
//! hay propagación material — A es el único provider, B llega a A
//! vía la conexión directa establecida en step 2 y obtiene el record
//! del store local de A.
//! - El test usa flow `monad-list:json` por familiaridad (es el flow
//! real que `akasha daemon` declara). Sirve también como prueba de
//! que el sistema completo (daemon + DHT) funcionaría con cero
//! cambios en la Card.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
ulid::Ulid, Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::network::{find_remote_providers, run_libp2p_accept_loop};
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Multiaddr, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
},
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_finds_remote_provider() {
// ---- Node A (provider): server + libp2p net + Card con output ----
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker.clone()),
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
policy: None,
},
)
.unwrap(),
);
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let a_addr = a_net.listen(listen_addr).await;
let mut a_full_addr = a_addr.clone();
a_full_addr.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Registrar la Card local en A con un flow output.
let card = provider_card("test.engine_remote", "monad-list", "json");
let mut local_client = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
// ---- Node B (consumer): otro net que dial-a a A ----
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full_addr.clone());
// Esperar a que la conexión se establezca y Identify popule el
// routing table de Kad. En localhost con 2 peers, ~250ms es de
// sobra; sumamos margen para CI.
tokio::time::sleep(Duration::from_millis(500)).await;
// ---- Discovery: B busca providers de "monad-list:json" ----
let providers = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.contains(&a_peer),
"B debería descubrir a A vía DHT. Encontrados: {:?}, esperado: {}",
providers,
a_peer
);
// Sanidad: el cliente local sigue vivo durante todo el test (lo
// que mantiene la Card registrada y por tanto el record DHT vivo).
local_client.farewell().await.ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_negative_unknown_flow() {
// Mismo setup que el test happy-path, pero B busca un flow que A
// NO ofrece. Debe devolver lista vacía dentro del timeout
// razonable (no colgarse).
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
let card = provider_card("test.engine_other", "monad-list", "json");
let mut local = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.unwrap();
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Buscamos un flow que NADIE anunció.
let providers = find_remote_providers(
&b_net,
"flow-que-no-existe",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.is_empty(),
"no debería haber providers para un flow inexistente, got: {:?}",
providers
);
local.farewell().await.ok();
}
/// stop_providing test: A registra Card con flow X, B descubre a A.
/// El cliente local de A hace farewell → cleanup llama
/// withdraw_outputs → A se quita del provider local store. Una nueva
/// query desde B (que rutea por A, único peer en el DHT) ya no debe
/// listarlo.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_withdraws_on_session_cleanup() {
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let sessions = a_server.sessions();
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Card con un flow output anunciable.
let card = provider_card("test.withdraws", "monad-list", "json");
let local = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Confirmación previa: A es discoverable.
let before = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
before.contains(&a_peer),
"antes del farewell A debería ser discoverable. got: {:?}",
before
);
// Farewell del cliente local → server.cleanup → withdraw_outputs.
local.farewell().await.ok();
// Esperamos a que la sesión salga del registro de A (señal de
// que cleanup completó).
let mut waited = 0;
while !sessions.lock().await.is_empty() && waited < 50 {
tokio::time::sleep(Duration::from_millis(20)).await;
waited += 1;
}
assert!(
sessions.lock().await.is_empty(),
"sesión debería estar removida tras farewell"
);
// Pequeño margen extra para que el Command::StopProviding lo
// procese el swarm task (no es await-able desde fuera).
tokio::time::sleep(Duration::from_millis(100)).await;
// Nueva query: A ya no debería listarse como provider.
let after = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
!after.contains(&a_peer),
"tras farewell + withdraw_outputs, A NO debería ser discoverable. got: {:?}",
after
);
}
@@ -0,0 +1,525 @@
//! Test E2E: handshake brahman remoto sobre libp2p stream.
//!
//! Pipeline:
//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
//! crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
//! montar `run_libp2p_accept_loop`.
//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
//! server; `connect_libp2p` con su Card; `ping`; `farewell`.
//! 3. Verificar: el server registró la sesión; sessions.len() == 1
//! durante la sesión, == 0 después del farewell.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
use brahman_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
use brahman_handshake::peer_policy::PeerPolicy;
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::default(),
priority: Priority::default(),
kind: CardKind::Ente,
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_roundtrip() {
// ---- Server side ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer_id = server_net.peer_id;
// Listen on a random TCP port.
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual_addr = server_net.listen(listen_addr).await;
// Inject the libp2p PeerId into the multiaddr so the client knows
// who to dial.
let mut full_addr = actual_addr.clone();
full_addr.push(Protocol::P2p(server_peer_id));
// Spawn the libp2p accept loop.
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Client side ----
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full_addr.clone());
// Pequeña espera para que el dial conecte. En un entorno real el
// caller usaría un mecanismo de barrier, pero para tests un sleep
// corto es suficiente y deterministic en localhost.
tokio::time::sleep(Duration::from_millis(200)).await;
let card = sample_card("test.remote_ente");
let client_kp = client_net.keypair();
let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp)
.await
.expect("handshake remoto debería completar");
// Verificación: el server vio la sesión.
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "una sesión registrada");
let resolved = s.values().next().unwrap();
assert_eq!(resolved.card.label, "test.remote_ente");
}
// Ping roundtrip.
let ts = client.ping().await.expect("ping debería responder");
assert!(ts > 0, "timestamp del Pong > 0");
// Farewell limpio.
client.farewell().await.expect("farewell debería completar");
// Tras el farewell, el cleanup remueve la sesión.
// Damos un tick para que el handler procese el frame.
tokio::time::sleep(Duration::from_millis(100)).await;
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "sesión removida tras farewell");
}
// peer_id no usado aquí, pero validamos que la API existe.
let _ = PeerId::random();
}
/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair
/// distinta a la del peer libp2p. El server (que verifica que la
/// public key del Hello derive al peer_id autenticado por Noise) debe
/// rechazar con `Unauthorized`.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_rejects_mismatched_signing_key() {
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual = server_net.listen(listen_addr).await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full);
tokio::time::sleep(Duration::from_millis(200)).await;
// Keypair fraudulenta: NO es la del client_net.
let evil_keypair = Keypair::generate_ed25519();
let card = sample_card("test.evil");
let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await;
assert!(
result.is_err(),
"handshake con keypair fraudulenta debe fallar"
);
// Sanidad: ninguna sesión registrada.
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "no debería haber sesión registrada");
}
/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`.
/// Un cliente con peer_id en la lista pasa el handshake; otro con
/// peer_id distinto es rechazado con `Unauthorized` ANTES de la
/// verificación de firma (la allowlist se chequea primero, es más
/// barata).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
// Pre-generamos las dos identidades cliente para que A pueda
// construir la allowlist conociendo cuál es la "permitida".
let allowed_kp = Keypair::generate_ed25519();
let allowed_peer = allowed_kp.public().to_peer_id();
let denied_kp = Keypair::generate_ed25519();
// (denied_peer no se necesita para la lista — sólo para clarity)
let _ = denied_kp.public().to_peer_id();
// ---- Server con allowlist activa ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
Some([allowed_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Cliente PERMITIDO ----
let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap();
allowed_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_ok = sample_card("test.allowed");
let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp)
.await
.expect("peer en allowlist debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "sesión del peer permitido registrada");
}
allowed_client.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- Cliente DENEGADO ----
let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap();
denied_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_no = sample_card("test.denied");
let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await;
assert!(
result.is_err(),
"peer fuera de allowlist debe ser rechazado, got: {:?}",
result.is_ok()
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado");
}
}
/// Denylist gate: A configura `policy` con un peer en la denylist.
/// Modo abierto para todo lo demás (sin allowlist), pero el peer
/// baneado es rechazado aún teniendo Ed25519 válida y peer_id que
/// derivaría limpio del Noise handshake.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_denylist_blocks_listed_peer() {
let banned_kp = Keypair::generate_ed25519();
let banned_peer = banned_kp.public().to_peer_id();
let other_kp = Keypair::generate_ed25519();
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
None, // sin allowlist (abierto)
[banned_peer].into_iter().collect(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// Cliente baneado: connect debe fallar.
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
banned_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_x = sample_card("test.banned");
let result = connect_libp2p(&banned_net, server_peer, card_x, None, &banned_kp).await;
assert!(
result.is_err(),
"peer en denylist debe ser rechazado, got Ok"
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "el peer baneado no debería tener sesión");
}
// Cliente no-baneado pasa.
let other_net = BrahmanNet::with_keypair(other_kp.clone()).unwrap();
other_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_ok = sample_card("test.other");
let mut other_client = connect_libp2p(&other_net, server_peer, card_ok, None, &other_kp)
.await
.expect("peer fuera de denylist debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "sesión del peer no-baneado registrada");
}
other_client.farewell().await.ok();
}
/// Swarm-level deny via `PeerPolicy::attach_to_net`: cuando la deny
/// se aplica al swarm vía `block_list`, el peer baneado es rechazado
/// en el dial — la conexión TCP/Noise nunca completa, así que el
/// cliente nunca llega siquiera a mandar el Hello. Más eficiente que
/// el handshake-level deny.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn swarm_level_deny_blocks_before_noise() {
let banned_kp = Keypair::generate_ed25519();
let banned_peer = banned_kp.public().to_peer_id();
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let policy = brahman_handshake::peer_policy::PeerPolicy::from_sets(
None,
[banned_peer].into_iter().collect(),
);
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(policy.clone()),
},
)
.unwrap(),
);
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
// ATTACH: la deny se proyecta al swarm. Es lo nuevo de este
// commit — sin esta llamada, el deny seguiría aplicando sólo
// al nivel de handshake brahman (lo que también funciona pero
// gasta un round-trip Noise).
policy.attach_to_net(server_net.clone());
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// Cliente baneado intenta dial + handshake. Con swarm-level
// deny, la conexión libp2p ni siquiera completa: `connect_libp2p`
// falla con error de open_stream (peer inalcanzable / connection
// refused) en lugar del Unauthorized del handshake-level path.
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
banned_net.dial(full.clone());
let card = sample_card("test.swarm_banned");
// Timeout corto: si el block falla, el handshake completaría
// rápido en localhost. Si funciona, debería fallar el dial casi
// instantáneo o colgarse hasta el timeout.
let result = tokio::time::timeout(
Duration::from_secs(3),
connect_libp2p(&banned_net, server_peer, card, None, &banned_kp),
)
.await;
match result {
Ok(Ok(_)) => panic!("peer baneado a nivel swarm NO debería completar handshake"),
Ok(Err(e)) => {
// Esperado: error de transporte/stream, no de handshake.
tracing::info!(error = %e, "swarm-level deny rechazó como esperado");
}
Err(_) => {
// También aceptable: timeout porque el dial nunca completa.
tracing::info!("swarm-level deny → connect timeout (también OK)");
}
}
}
/// Multi-key identity: la propiedad fundamental que cierra el
/// proyecto. El cliente B tiene una identity master estable; el
/// server A le permite el master_peer en allowlist. B se conecta con
/// **session1**; pasa. B "rota": genera **session2** distinta, emite
/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
/// también — sin que A toque su allowlist.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn identity_cert_allows_session_rotation_without_policy_change() {
// Master de B (estable, persistente).
let master_kp = Keypair::generate_ed25519();
let master_peer = master_kp.public().to_peer_id();
let identity = Identity::from_keypair(master_kp);
// A configura policy: allowlist con master_peer (NO sessions).
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
Some([master_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Conexión 1: session1 ----
let session1_kp = Keypair::generate_ed25519();
let cert1 = identity
.issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
net1.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client1 = connect_libp2p_with_cert(
&net1,
server_peer,
sample_card("test.session1"),
None,
&session1_kp,
cert1,
)
.await
.expect("session1 con cert válido del master allowlisted debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session1 registrada");
}
client1.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- ROTACIÓN: session2 distinta, mismo master ----
let session2_kp = Keypair::generate_ed25519();
assert_ne!(
session1_kp.public().to_peer_id(),
session2_kp.public().to_peer_id(),
"test inválido si las sessions son iguales"
);
let cert2 = identity
.issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
net2.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client2 = connect_libp2p_with_cert(
&net2,
server_peer,
sample_card("test.session2"),
None,
&session2_kp,
cert2,
)
.await
.expect(
"session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session2 registrada");
}
client2.farewell().await.ok();
// Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
// NO está en la allowlist (porque la allowlist tiene master, no
// sessions) DEBE ser rechazada.
let session_other = Keypair::generate_ed25519();
let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
net_other.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let result = connect_libp2p(
&net_other,
server_peer,
sample_card("test.no_cert"),
None,
&session_other,
)
.await;
assert!(
result.is_err(),
"sin cert, session_peer_id (no listado) debe ser rechazado"
);
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "brahman-net"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — capa de transporte P2P compartida (libp2p TCP+Noise+Yamux+Kad+Identify+Stream). Cualquier protocolo (handshake brahman, sync minga, futuros) puede registrar su StreamProtocol y abrir/aceptar streams sobre la malla común."
[dependencies]
futures = { workspace = true }
libp2p = { workspace = true }
libp2p-stream = { workspace = true }
libp2p-allow-block-list = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
+423
View File
@@ -0,0 +1,423 @@
//! `brahman-net` — capa P2P compartida de la red Brahman.
//!
//! Provee un nodo libp2p genérico que cualquier protocolo de la
//! familia (handshake brahman remoto, sync minga, futuros) puede
//! reusar. La idea: una sola malla, múltiples sub-protocolos
//! multiplexados por `StreamProtocol`.
//!
//! ## Stack
//!
//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
//! - **`stream::Behaviour`**: streams bidireccionales por
//! `StreamProtocol`. Cada protocolo (`/brahman/handshake/1.0.0`,
//! `/minga/sync/1.0.0`, …) se registra independientemente vía el
//! `stream::Control` que `BrahmanNet` expone.
//! - **`kad::Behaviour<MemoryStore>`**: Kademlia DHT en modo Server
//! para discovery (peers cercanos + content providers).
//! - **`identify::Behaviour`**: cada peer anuncia sus listen-addrs
//! reales; las inyectamos automáticamente al routing table de Kad.
//!
//! ## Modelo
//!
//! El swarm corre en una task tokio dedicada. La interfaz pública son:
//! 1. **Comandos** (canal mpsc): `dial`, `listen`, `add_dht_peer`,
//! `find_closest_peers`, `start_providing`, `find_providers`.
//! 2. **`stream::Control`** (acceso directo): para abrir/aceptar
//! streams de un protocolo concreto. Cada protocolo se ocupa de
//! su propia lógica sobre el stream resultante.
//!
//! La separación entre comandos y control permite que la lógica de
//! red (DHT, dial, listen) y la lógica de protocolos (handshake/sync)
//! evolucionen independientes — el protocolo no necesita conocer al
//! swarm, sólo pide streams.
//!
//! ## Identidad
//!
//! Por defecto se genera una keypair Ed25519 efímera. Para identidad
//! persistente (la misma `peer_id` across reboots), pasar la keypair
//! con [`BrahmanNet::with_keypair`]. Esa misma keypair puede ser la
//! base para firmas de Cards (cuando se implemente trust remoto).
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use libp2p::{
identify, identity, kad, noise,
swarm::{NetworkBehaviour, SwarmEvent},
tcp, yamux, Swarm, SwarmBuilder,
};
use libp2p_allow_block_list::{self as allow_block_list, BlockedPeers};
use libp2p_stream as stream;
use tokio::sync::{mpsc, oneshot, Mutex};
pub use libp2p::{
identity::{Keypair, PublicKey},
multiaddr::Protocol,
Multiaddr, PeerId, Stream, StreamProtocol,
};
pub use libp2p_stream::OpenStreamError;
const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0";
const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(NetworkBehaviour)]
struct BrahmanBehaviour {
/// Block-list a nivel de swarm: peers en este behaviour son
/// rechazados ANTES del handshake Noise. Más eficiente que
/// rechazar al nivel del handshake brahman (ahorra round-trip
/// TCP+Noise por intento denegado). Sincronizado con la
/// `PeerPolicy.deny` vía `block_peer`/`unblock_peer` exposed
/// en `BrahmanNet`.
block_list: allow_block_list::Behaviour<BlockedPeers>,
stream: stream::Behaviour,
kad: kad::Behaviour<kad::store::MemoryStore>,
identify: identify::Behaviour,
}
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("transport build failed: {0}")]
Build(String),
}
#[derive(Debug)]
enum Command {
Dial(Multiaddr),
Listen(Multiaddr),
AddDhtPeer(PeerId, Multiaddr),
FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
StartProviding(Vec<u8>),
StopProviding(Vec<u8>),
GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
BlockPeer(PeerId),
UnblockPeer(PeerId),
}
/// Peer descubierto vía DHT: identidad + direcciones conocidas.
#[derive(Debug, Clone)]
pub struct DiscoveredPeer {
pub peer_id: PeerId,
pub addrs: Vec<Multiaddr>,
}
/// Nodo Brahman en la malla P2P. Maneja el swarm libp2p y expone
/// API uniforme para listen/dial/DHT/streams.
pub struct BrahmanNet {
/// Identidad libp2p de este nodo. Estable mientras viva la
/// keypair (efímera por default; persistente si pasaste una
/// vía [`with_keypair`]).
pub peer_id: PeerId,
/// Keypair compartida (Arc para compartir con consumers que
/// necesitan firmar mensajes con la misma identidad — p. ej.
/// `brahman_handshake::network::connect_libp2p` que firma el
/// Hello). NO se expone públicamente; usar [`Self::keypair`].
keypair: Arc<Keypair>,
cmd_tx: mpsc::UnboundedSender<Command>,
listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
/// Control para abrir y aceptar streams. Cada protocolo
/// (handshake brahman, sync minga, etc.) llama
/// `control.accept(StreamProtocol::new("/foo/1.0.0"))` para
/// recibir streams entrantes, o `control.open_stream(peer, proto)`
/// para abrirlos. Multiplexado y demultiplexado lo hace libp2p.
pub control: stream::Control,
}
impl BrahmanNet {
/// Crea un nodo con keypair Ed25519 generada al vuelo (peer_id
/// efímero — cambia en cada arranque).
pub fn new() -> Result<Self, NodeError> {
Self::with_keypair(identity::Keypair::generate_ed25519())
}
/// Crea un nodo con una keypair libp2p específica. Usá esto para
/// `peer_id` estable (por ejemplo si tu identidad se persiste a
/// disco, o si la derivás de la identidad criptográfica del
/// módulo).
///
/// Sólo Ed25519 se soporta — la `keypair` se duplica internamente
/// vía clone del `ed25519::Keypair` para que tanto el swarm
/// (Noise auth) como el caller (firma de Cards) compartan la
/// misma identidad sin la fricción de que `identity::Keypair` no
/// implemente `Clone`.
pub fn with_keypair(keypair: identity::Keypair) -> Result<Self, NodeError> {
let ed_kp = keypair
.try_into_ed25519()
.map_err(|_| NodeError::Build("brahman-net sólo soporta keypairs Ed25519".into()))?;
let kp_for_swarm = identity::Keypair::from(ed_kp.clone());
let kp_for_storage = Arc::new(identity::Keypair::from(ed_kp));
let peer_id = kp_for_swarm.public().to_peer_id();
let mut swarm: Swarm<BrahmanBehaviour> = SwarmBuilder::with_existing_identity(kp_for_swarm)
.with_tokio()
.with_tcp(
tcp::Config::default(),
noise::Config::new,
yamux::Config::default,
)
.map_err(|e| NodeError::Build(format!("{e}")))?
.with_behaviour(|key| {
let local = key.public().to_peer_id();
let mut kad =
kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
// Modo Server: respondemos a queries del DHT. Auto
// requiere detectar reachability; para entornos
// controlados (localhost, redes privadas) Server es
// lo correcto.
kad.set_mode(Some(kad::Mode::Server));
let identify = identify::Behaviour::new(
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
.with_agent_version(format!("brahman-net/{}", env!("CARGO_PKG_VERSION"))),
);
BrahmanBehaviour {
block_list: allow_block_list::Behaviour::default(),
stream: stream::Behaviour::new(),
kad,
identify,
}
})
.map_err(|e| NodeError::Build(format!("{e}")))?
.with_swarm_config(|c| c.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
.build();
let control = swarm.behaviour().stream.new_control();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
tokio::spawn(async move {
let mut pending_finds: HashMap<
kad::QueryId,
oneshot::Sender<Vec<DiscoveredPeer>>,
> = HashMap::new();
let mut pending_providers: HashMap<
kad::QueryId,
(Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
> = HashMap::new();
loop {
tokio::select! {
Some(cmd) = cmd_rx.recv() => {
match cmd {
Command::Dial(addr) => {
let _ = swarm.dial(addr);
}
Command::Listen(addr) => {
let _ = swarm.listen_on(addr);
}
Command::AddDhtPeer(peer, addr) => {
swarm.behaviour_mut().kad.add_address(&peer, addr);
}
Command::FindClosestPeers(target, tx) => {
let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
pending_finds.insert(qid, tx);
}
Command::StartProviding(key) => {
// Best-effort: si falla (sin peers cercanos para
// replicar), seguirá viviendo en el local store
// y se servirá vía get_providers de quien tenga
// conexión con nosotros.
let _ = swarm.behaviour_mut().kad.start_providing(key.into());
}
Command::StopProviding(key) => {
// Quitamos el record local del provider store.
// Los peers cercanos eventualmente expiran su
// copia replicada por TTL natural (~24h en
// libp2p kad default); para retiro inmediato
// habría que enviar un republish con sentinel,
// pero kad no expone esa primitiva. Aceptable
// para el caso "el provider local desapareció":
// queries que pasen por nosotros dejan de
// listarnos al instante.
swarm.behaviour_mut().kad.stop_providing(&key.into());
}
Command::GetProviders(key, tx) => {
let qid = swarm.behaviour_mut().kad.get_providers(key.into());
pending_providers.insert(qid, (Vec::new(), tx));
}
Command::BlockPeer(peer) => {
swarm.behaviour_mut().block_list.block_peer(peer);
}
Command::UnblockPeer(peer) => {
swarm.behaviour_mut().block_list.unblock_peer(peer);
}
}
}
event = swarm.select_next_some() => {
match event {
SwarmEvent::NewListenAddr { address, .. } => {
let _ = listen_tx.send(address);
}
// Identify nos dice las listen-addrs reales del
// peer. Las inyectamos a Kad para poblar el
// routing table sin necesidad de add_dht_peer
// manual — la propagación pasa a ser automática.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Identify(
identify::Event::Received { peer_id, info, .. }
)) => {
for addr in info.listen_addrs {
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
}
}
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Kad(
kad::Event::OutboundQueryProgressed { id, result, step, .. }
)) => {
match result {
kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
if let Some(tx) = pending_finds.remove(&id) {
let infos = ok.peers.into_iter()
.map(|p| DiscoveredPeer {
peer_id: p.peer_id,
addrs: p.addrs,
})
.collect();
let _ = tx.send(infos);
}
}
kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
if let Some(tx) = pending_finds.remove(&id) {
let _ = tx.send(Vec::new());
}
}
kad::QueryResult::GetProviders(Ok(ok)) => {
if let Some((collected, _)) =
pending_providers.get_mut(&id)
{
if let kad::GetProvidersOk::FoundProviders {
providers, ..
} = ok
{
for p in providers {
if !collected.contains(&p) {
collected.push(p);
}
}
}
}
if step.last {
if let Some((providers, tx)) =
pending_providers.remove(&id)
{
let _ = tx.send(providers);
}
}
}
kad::QueryResult::GetProviders(Err(_)) if step.last => {
if let Some((providers, tx)) =
pending_providers.remove(&id)
{
let _ = tx.send(providers);
}
}
_ => {}
}
}
_ => {}
}
}
}
}
});
Ok(Self {
peer_id,
keypair: kp_for_storage,
cmd_tx,
listen_rx: Mutex::new(listen_rx),
control,
})
}
/// Acceso a la keypair de identidad del nodo. Usar para firmar
/// payloads que viajan asociados al `peer_id` (handshake brahman
/// firmado, futuros sub-protocolos con autenticación). El `Arc`
/// permite compartir sin copia — la keypair libp2p no es `Clone`.
pub fn keypair(&self) -> Arc<Keypair> {
self.keypair.clone()
}
/// Bloquea conexiones desde/hacia `peer` a nivel del swarm.
/// Conexiones existentes se cierran y nuevos intentos son
/// rechazados ANTES del Noise handshake — más eficiente que
/// rechazar al nivel del handshake brahman (ahorra round-trip
/// TCP+Noise por intento). Idempotente.
pub fn block_peer(&self, peer: PeerId) {
let _ = self.cmd_tx.send(Command::BlockPeer(peer));
}
/// Quita a `peer` de la block-list del swarm. Conexiones futuras
/// son aceptadas con normalidad. Idempotente.
pub fn unblock_peer(&self, peer: PeerId) {
let _ = self.cmd_tx.send(Command::UnblockPeer(peer));
}
/// Empieza a escuchar en `addr`. Bloquea hasta que el listener
/// publique su dirección real (Multiaddr resuelta — útil cuando
/// pediste `/ip4/0.0.0.0/tcp/0` y querés saber qué puerto te tocó).
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
self.cmd_tx
.send(Command::Listen(addr))
.expect("swarm task alive");
let mut rx = self.listen_rx.lock().await;
rx.recv().await.expect("listen address arrives")
}
/// Inicia conexión con un peer en `addr`. No-op si ya hay
/// conexión. Best-effort — fallos se loggean al swarm pero no se
/// propagan al caller (consistente con libp2p).
pub fn dial(&self, addr: Multiaddr) {
let _ = self.cmd_tx.send(Command::Dial(addr));
}
/// Añade un peer al routing table de Kademlia. Punto de entrada
/// para bootstrap: tras esto, el nodo puede dirigir queries DHT
/// a través de este peer.
pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
}
/// Consulta el DHT por los peers más cercanos al `target` PeerId.
/// Devuelve la lista resuelta (vacía si la query falla o si no
/// hay peers conocidos). Bloquea hasta que la query completa.
pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
let (tx, rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(Command::FindClosestPeers(target, tx));
rx.await.unwrap_or_default()
}
/// Anuncia en el DHT que este peer tiene el contenido identificado
/// por `key`. Otros peers pueden luego descubrirlo vía
/// [`find_providers`](Self::find_providers). Best-effort: si la
/// replicación falla inicialmente, el record vive en el store
/// local hasta que llegue conexión.
pub fn start_providing(&self, key: &[u8]) {
let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
}
/// Retira el anuncio previo de [`start_providing`] para `key`.
/// El record local se borra al instante (queries que lleguen a
/// nosotros dejan de listarnos). Los records replicados en peers
/// remotos viven hasta su TTL — kad no expone primitiva para
/// retracción inmediata cross-peer. Aceptable: simétrico al
/// caso "el provider apareció" (también propagación eventual).
pub fn stop_providing(&self, key: &[u8]) {
let _ = self.cmd_tx.send(Command::StopProviding(key.to_vec()));
}
/// Consulta el DHT por peers que han anunciado proveer `key`.
/// Devuelve la lista de `PeerId`s que se reportan como providers.
/// Lista vacía si nadie anuncia.
pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
let (tx, rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(Command::GetProviders(key.to_vec(), tx));
rx.await.unwrap_or_default()
}
}
@@ -0,0 +1,28 @@
[package]
name = "brahman-sidecar"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — sidecar reusable: thread + tokio runtime que mantiene viva la sesión de un módulo contra el Init."
[dependencies]
brahman-card = { path = "../brahman-card" }
brahman-handshake = { path = "../brahman-handshake" }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tracing-subscriber = { workspace = true }
brahman-card-wit = { path = "../brahman-card-wit" }
[[example]]
name = "presence"
path = "examples/presence.rs"
[[example]]
name = "presence-conscious"
path = "examples/presence-conscious.rs"
@@ -0,0 +1,99 @@
//! `presence-conscious` — módulo brahman que se presenta con su WIT.
//!
//! Variante de [`presence`] que toma un path a un archivo `.wit` (default
//! `shared_wit/protocol.wit` resuelto desde el cwd) y lo parsea con
//! `brahman-card-wit` antes de spawnear el sidecar. Demuestra el flujo
//! "módulo consciente": Hello incluye `WitInterface`, el server lo
//! registra como `ResolvedCard::from_conscious`, y aparece con marker
//! 🧠 en `brahman-status`.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-sidecar --example presence-conscious -- mi-modulo [path/al.wit]
//! ```
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::time::Duration;
use brahman_card::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_sidecar::{spawn_with_handle, SidecarConfig};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let mut args = std::env::args().skip(1);
let label = args.next().unwrap_or_else(|| "conscious-default".into());
let wit_path: PathBuf = args
.next()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("shared_wit/protocol.wit"));
let wit = match brahman_card_wit::parse_wit_file(&wit_path) {
Ok(worlds) => match worlds.into_iter().next() {
Some(w) => {
eprintln!(
"[{label}] cargado wit: {} / {}",
w.package, w.world
);
Some(w)
}
None => {
eprintln!("[{label}] {} no declara worlds", wit_path.display());
None
}
},
Err(e) => {
eprintln!("[{label}] falló parse de {}: {e}", wit_path.display());
None
}
};
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![Flow {
name: "out".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
};
let config = SidecarConfig {
card,
wit,
ping_interval: Duration::from_secs(5),
};
let _handle = spawn_with_handle(config);
eprintln!("[{label}] sidecar lanzado, durmiendo (Ctrl-C para salir)");
std::thread::park();
}
@@ -0,0 +1,70 @@
//! `presence` — módulo brahman dummy para pruebas y demos.
//!
//! Declara una Card mínima con label tomado del primer argumento (default
//! `presence-default`) y mantiene la sesión viva hasta SIGTERM/SIGINT.
//! Útil para poblar el broker con sesiones de prueba.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-sidecar --example presence -- mi-modulo
//! ```
use std::collections::BTreeSet;
use std::time::Duration;
use brahman_card::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_sidecar::{spawn_with_handle, SidecarConfig};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let label = std::env::args()
.nth(1)
.unwrap_or_else(|| "presence-default".into());
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![Flow {
name: "out".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
};
let _handle = spawn_with_handle(SidecarConfig {
card,
wit: None,
ping_interval: Duration::from_secs(5),
});
eprintln!("presence({label}): sidecar lanzado, durmiendo (Ctrl-C para salir)");
std::thread::park();
}
@@ -0,0 +1,311 @@
//! `brahman-sidecar::discovery` — API reusable para que un módulo
//! consumer encuentre proveedores vivos vía broker, sin hardcodear
//! sockets ni reimplementar el patrón a mano.
//!
//! Es la generalización de `discover_producer_socket` del CLI
//! `akasha attract --remote`: declarás el `TypeRef` que querés
//! consumir y el broker te empuja un `MatchEvent::Available` con el
//! `producer_service_socket` del primer proveedor matched.
//!
//! Pipeline:
//! 1. `build_consumer_card(label, flow_name, type_name)` arma una
//! Card mínima (Ente, Oneshot, Virtual) con un input flow.
//! 2. `await_provider(card, timeout)` se conecta al brahman-init,
//! espera hasta `timeout` por `MatchEvent::Available`, devuelve
//! el socket del proveedor electo, y envía Farewell.
//! 3. Para mundos blocking (CLIs, tests, std-thread loops) hay
//! `await_provider_blocking` que arma su propio runtime
//! `current_thread`.
//!
//! Quién elige al proveedor es el broker, no este módulo. Si el
//! broker tiene `priority_contexts` activo, podés cambiar de
//! proveedor sin tocar el consumer; el matching dinámico se respeta.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use brahman_card::{
Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
};
use brahman_handshake::client::{Client, ClientError};
use brahman_handshake::messages::MatchEventKind;
use brahman_handshake::transport;
#[derive(Debug, thiserror::Error)]
pub enum ConsumerError {
#[error("no se pudo conectar al init en {socket}: {source}")]
Connect {
socket: PathBuf,
#[source]
source: ClientError,
},
#[error("error en cliente brahman: {0}")]
Client(#[from] ClientError),
#[error("timeout {timeout:?} sin proveedor disponible para flow '{flow}' (type '{type_ref}')")]
NoProvider {
flow: String,
type_ref: String,
timeout: Duration,
},
#[error("no se pudo crear runtime tokio: {0}")]
Runtime(String),
}
/// Construye una Card mínima de consumer que declara un input flow
/// con el `TypeRef::Primitive { name }` solicitado. Usá esto para
/// el caso común; si necesitás algo más rico (output flows,
/// permissions, references) construí la Card a mano y pasala a
/// [`await_provider`] directamente.
pub fn build_consumer_card(
consumer_label: impl Into<String>,
flow_name: impl Into<String>,
type_name: impl Into<String>,
) -> Card {
Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
output: vec![],
},
..Card::new(consumer_label)
}
}
/// Conecta al brahman-init, registra `consumer_card`, espera el
/// primer `MatchEvent::Available` y devuelve el `producer_service_socket`
/// que el broker emitió. Cierra la sesión con Farewell antes de
/// retornar (best-effort).
///
/// La `consumer_card` debe declarar al menos un `flow.input`; si no,
/// el broker no puede hacer matching y el await siempre dará timeout.
pub async fn await_provider(
consumer_card: Card,
timeout: Duration,
) -> Result<PathBuf, ConsumerError> {
let init_path = transport::default_socket_path();
// Capturamos descriptor para el mensaje de error antes de mover
// la card al cliente.
let (flow_name, type_ref_name) = describe_first_input(&consumer_card);
let mut client = Client::connect(&init_path, consumer_card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let deadline = Instant::now() + timeout;
let socket = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break None;
}
match client.await_event(remaining).await? {
Some(ev) if ev.kind == MatchEventKind::Available => {
break ev.producer_service_socket;
}
Some(_) => continue, // Lost u otros: seguir esperando hasta el deadline
None => break None,
}
};
let _ = client.farewell().await; // best-effort cleanup
socket.ok_or(ConsumerError::NoProvider {
flow: flow_name,
type_ref: type_ref_name,
timeout,
})
}
/// Wrapper bloqueante de [`await_provider`]. Crea un runtime tokio
/// `current_thread` efímero y bloquea el thread llamador. Útil para
/// CLIs, tests y módulos std-thread (p. ej. el frontend GPUI antes
/// de tener su propio runtime async).
pub fn await_provider_blocking(
consumer_card: Card,
timeout: Duration,
) -> Result<PathBuf, ConsumerError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(await_provider(consumer_card, timeout))
}
/// Conecta al brahman-init con una Card observer (sin inputs ni
/// outputs) y pide la lista de sesiones activas. Útil para
/// herramientas de observabilidad (broker-explorer, CLIs).
///
/// El observer se identifica con `observer_label`. La sesión se
/// cierra con Farewell antes de retornar (best-effort).
pub async fn list_sessions(
observer_label: impl Into<String>,
) -> Result<brahman_handshake::messages::SessionList, ConsumerError> {
let init_path = transport::default_socket_path();
// Card mínima sin flow.input/output: el observer no participa en
// matching, sólo establece sesión para poder consultar.
let card = Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![],
},
..Card::new(observer_label)
};
let mut client = Client::connect(&init_path, card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let list = client.list_sessions().await?;
let _ = client.farewell().await;
Ok(list)
}
/// Wrapper bloqueante de [`list_sessions`]. Idéntico patrón a
/// `await_provider_blocking`: runtime current_thread efímero.
pub fn list_sessions_blocking(
observer_label: impl Into<String>,
) -> Result<brahman_handshake::messages::SessionList, ConsumerError> {
let label = observer_label.into();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(list_sessions(label))
}
/// Análogo a `list_sessions` pero pide los matches activos del
/// broker. La Card observer es la misma forma minimalista (sin
/// flow.input/output) — el endpoint no requiere participar en
/// matching.
pub async fn list_matches(
observer_label: impl Into<String>,
) -> Result<brahman_handshake::messages::MatchList, ConsumerError> {
let init_path = transport::default_socket_path();
let card = Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![],
},
..Card::new(observer_label)
};
let mut client = Client::connect(&init_path, card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let list = client.list_matches().await?;
let _ = client.farewell().await;
Ok(list)
}
/// Wrapper bloqueante de [`list_matches`].
pub fn list_matches_blocking(
observer_label: impl Into<String>,
) -> Result<brahman_handshake::messages::MatchList, ConsumerError> {
let label = observer_label.into();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(list_matches(label))
}
fn describe_first_input(card: &Card) -> (String, String) {
match card.flow.input.first() {
Some(flow) => {
let type_name = match &flow.ty {
TypeRef::Primitive { name } => name.clone(),
TypeRef::Wit { package, name, .. } => format!("{package}#{name}"),
};
(flow.name.clone(), type_name)
}
None => ("(sin input)".into(), "(sin tipo)".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use brahman_card::ulid::Ulid;
#[test]
fn builder_sets_input_flow_with_primitive_type() {
let c = build_consumer_card("akasha.cli", "embed-result", "json");
assert_eq!(c.label, "akasha.cli");
assert_eq!(c.kind, CardKind::Ente);
assert!(matches!(c.lifecycle, Lifecycle::Oneshot));
assert!(matches!(c.supervision, Supervision::OneShot));
assert_eq!(c.flow.input.len(), 1);
let f = &c.flow.input[0];
assert_eq!(f.name, "embed-result");
match &f.ty {
TypeRef::Primitive { name } => assert_eq!(name, "json"),
_ => panic!("expected primitive type"),
}
assert!(c.flow.output.is_empty());
// El builder asigna un id real (no nil) — fundamental para que
// el broker no colisione con otros consumers.
assert!(c.id != Ulid::nil(), "consumer card id no debe ser nil");
}
#[test]
fn builder_assigns_distinct_ids_per_call() {
let a = build_consumer_card("a", "f", "t");
let b = build_consumer_card("a", "f", "t");
assert_ne!(a.id, b.id, "cada Card debería tener id propio");
}
#[test]
fn describe_falls_back_when_no_input_flow() {
let mut c = build_consumer_card("x", "f", "t");
c.flow.input.clear();
let (flow, ty) = describe_first_input(&c);
assert_eq!(flow, "(sin input)");
assert_eq!(ty, "(sin tipo)");
}
#[test]
fn describe_formats_wit_type() {
let mut c = build_consumer_card("x", "f", "t");
c.flow.input[0].ty = TypeRef::Wit {
package: "brahman:dht".into(),
interface: None,
name: "entity-result".into(),
};
let (_, ty) = describe_first_input(&c);
assert_eq!(ty, "brahman:dht#entity-result");
}
}
+254
View File
@@ -0,0 +1,254 @@
//! `brahman-sidecar` — boilerplate del cliente brahman extraído.
//!
//! Cualquier módulo que quiera presentarse al Init brahman pero que tenga
//! su propio runtime (GPUI, current_thread tokio, std-thread loop, etc.)
//! puede llamar [`spawn`] con su [`brahman_card::Card`]. Eso arma un
//! thread aparte con un runtime tokio current_thread, conecta al Init,
//! y mantiene la sesión viva con pings periódicos.
//!
//! Si el Init no está disponible, el thread loggea y termina — el módulo
//! sigue funcionando standalone.
//!
//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si querés
//! capturar la salida del thread (por ejemplo para test), usá
//! [`spawn_with_handle`] que devuelve un `JoinHandle`.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod discovery;
pub use discovery::{
await_provider, await_provider_blocking, build_consumer_card, list_matches,
list_matches_blocking, list_sessions, list_sessions_blocking, ConsumerError,
};
use std::collections::HashMap;
use std::sync::{mpsc, Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use brahman_card::{ulid::Ulid, Card, WitInterface};
use brahman_handshake::{client::Client, transport};
use tokio::task::AbortHandle;
use tracing::{info, warn};
/// Período entre pings al Init.
pub const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(30);
/// Configuración del sidecar.
#[derive(Debug, Clone)]
pub struct SidecarConfig {
/// Card que se presenta al Init.
pub card: Card,
/// WIT interface opcional. Si es `Some`, el módulo se registra como
/// "consciente" (`ResolvedCard::from_conscious`).
pub wit: Option<WitInterface>,
/// Período entre pings.
pub ping_interval: Duration,
}
impl SidecarConfig {
/// Configuración agnóstica con defaults razonables (sin WIT, ping 30s).
pub fn new(card: Card) -> Self {
Self {
card,
wit: None,
ping_interval: DEFAULT_PING_INTERVAL,
}
}
/// Configuración consciente con WIT extraída.
pub fn with_wit(mut self, wit: WitInterface) -> Self {
self.wit = Some(wit);
self
}
}
/// Spawn fire-and-forget agnóstico. Para módulos conscientes usá
/// [`spawn_conscious`] o [`spawn_with_handle`] con un `SidecarConfig`
/// personalizado.
pub fn spawn(card: Card) {
if let Err(e) = spawn_with_handle(SidecarConfig::new(card)) {
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
}
}
/// Spawn fire-and-forget con WIT. Idéntico a [`spawn`] pero el módulo se
/// registra como consciente en el broker.
pub fn spawn_conscious(card: Card, wit: WitInterface) {
if let Err(e) = spawn_with_handle(SidecarConfig::new(card).with_wit(wit)) {
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
}
}
/// Spawn devolviendo el `JoinHandle` para tests o cleanup explícito.
pub fn spawn_with_handle(config: SidecarConfig) -> std::io::Result<JoinHandle<()>> {
std::thread::Builder::new()
.name("brahman-sidecar".into())
.spawn(move || run_thread(config))
}
// =====================================================================
// SidecarPool — un solo runtime tokio compartido por N sesiones
// =====================================================================
/// Pool consolidado: un único thread con un runtime tokio
/// `current_thread` que hostea N tasks de sidecar simultáneas.
///
/// Para módulos con muchas sesiones (p. ej. `akasha daemon` publicando
/// 50+ Mónadas), evita el costo de tener un thread+runtime por cada
/// sesión.
///
/// **API**:
/// - `SidecarPool::new()` crea el pool (spawn del thread runtime).
/// - `pool.spawn(card)` añade una sesión sin WIT.
/// - `pool.spawn_conscious(card, wit)` añade una sesión con WIT.
/// - `pool.spawn_with_config(config)` para configuración custom.
///
/// El pool se mantiene vivo mientras exista. Si el `SidecarPool`
/// se dropea, el thread interno termina y todas las sesiones cierran.
pub struct SidecarPool {
handle: tokio::runtime::Handle,
/// Sesiones vivas indexadas por `Card.id`. Permite que un nuevo
/// `spawn` con el mismo id aborte la sesión previa — útil cuando
/// un módulo (p. ej. `akasha daemon`) re-publica una Mónada cuya
/// composición cambió.
sessions: Arc<Mutex<HashMap<Ulid, AbortHandle>>>,
_thread: JoinHandle<()>,
}
impl SidecarPool {
/// Crea un pool nuevo. Bloquea hasta que el runtime esté listo.
pub fn new() -> std::io::Result<Self> {
let (handle_tx, handle_rx) = mpsc::sync_channel::<tokio::runtime::Handle>(0);
let thread = std::thread::Builder::new()
.name("brahman-sidecar-pool".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!(error = %e, "tokio runtime falló — pool muerto");
return;
}
};
if handle_tx.send(rt.handle().clone()).is_err() {
return;
}
// Mantenemos el runtime vivo mientras existan tasks.
rt.block_on(std::future::pending::<()>());
})?;
let handle = handle_rx
.recv()
.map_err(|_| std::io::Error::other("pool runtime no respondió"))?;
Ok(Self {
handle,
sessions: Arc::new(Mutex::new(HashMap::new())),
_thread: thread,
})
}
/// Añade una sesión agnóstica al pool (sin WIT).
pub fn spawn(&self, card: Card) {
self.spawn_with_config(SidecarConfig::new(card));
}
/// Añade una sesión consciente (con WIT) al pool.
pub fn spawn_conscious(&self, card: Card, wit: WitInterface) {
self.spawn_with_config(SidecarConfig::new(card).with_wit(wit));
}
/// Añade una sesión con configuración custom.
///
/// Si ya existía una sesión para el mismo `Card.id`, la previa
/// se aborta antes de spawnear la nueva. Esto hace `spawn`
/// idempotente respecto al id: re-publicar una Mónada cuya
/// composición cambió "refresca" la sesión en el broker.
pub fn spawn_with_config(&self, config: SidecarConfig) {
let card_id = config.card.id;
let join = self.handle.spawn(run_client(config));
let abort = join.abort_handle();
if let Ok(mut sessions) = self.sessions.lock() {
if let Some(prev) = sessions.insert(card_id, abort) {
prev.abort();
}
}
}
/// Cierra explícitamente la sesión asociada a `card_id`. No-op si
/// no había sesión registrada.
pub fn drop_session(&self, card_id: Ulid) {
if let Ok(mut sessions) = self.sessions.lock() {
if let Some(abort) = sessions.remove(&card_id) {
abort.abort();
}
}
}
/// Cantidad actual de sesiones vivas (estimada — puede haber
/// drift transitorio entre abort y limpieza).
pub fn live_sessions(&self) -> usize {
self.sessions.lock().map(|s| s.len()).unwrap_or(0)
}
}
impl Default for SidecarPool {
fn default() -> Self {
Self::new().expect("falló SidecarPool::new")
}
}
fn run_thread(config: SidecarConfig) {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!(error = %e, "tokio runtime falló");
return;
}
};
rt.block_on(run_client(config));
}
/// Bucle async del sidecar. Público para que `SidecarPool` lo use vía
/// `handle.spawn(run_client(...))` desde código externo al runtime.
pub async fn run_client(config: SidecarConfig) {
let path = transport::default_socket_path();
let conscious = config.wit.is_some();
let mut client = match Client::connect_with(&path, config.card, config.wit).await {
Ok(c) => {
info!(
target: "brahman_sidecar",
session = %c.session(),
init_attached = c.server_info().init_attached,
server = %c.server_info().server_version,
conscious,
"attached"
);
c
}
Err(e) => {
warn!(
target: "brahman_sidecar",
error = %e,
socket = %path.display(),
"no conectado"
);
return;
}
};
loop {
tokio::time::sleep(config.ping_interval).await;
if let Err(e) = client.ping().await {
warn!(target: "brahman_sidecar", error = %e, "ping falló — terminando sidecar");
return;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "ente-card"
version = "0.0.1"
edition.workspace = true
license.workspace = true
publish.workspace = true
description = "Alias histórico de brahman-card. Re-exporta tipos legacy (EntityCard ≡ Card)."
[dependencies]
brahman-card = { path = "../brahman-card" }
+30
View File
@@ -0,0 +1,30 @@
//! `ente-card` — alias histórico de [`brahman_card`].
//!
//! Mantenido como compatibilidad para los crates `ente-*` del Init que
//! importan `EntityCard`, `Capability`, `Payload`, etc. La fuente de verdad
//! del schema vive en [`brahman_card`]; este crate sólo re-exporta los tipos
//! bajo sus nombres legacy:
//!
//! - `EntityCard` ≡ [`brahman_card::Card`]
//! - El resto de tipos conservan el mismo nombre.
//!
//! Toda lógica nueva debe consumir directamente `brahman_card`.
#![forbid(unsafe_code)]
pub use brahman_card::{
Capability,
CardError,
Card as EntityCard,
CgroupSpec,
DeviceClass,
InterfaceId,
LegacyFacade,
NamespaceSet,
NetlinkFamily,
Payload,
ResourceLimits,
SomaSpec,
Supervision,
CARD_SCHEMA_VERSION,
};