feat(core): brahman-card — Tarjeta de Presentación canónica híbrida
Crate nuevo en crates/core/brahman-card que unifica el modelo de arje y el de brahman/core_protocol original: De arje (ente-card): - Identidad ULID + lineage opcional - Capability tipado (Spawn, Journal, Endpoint, Device, Netlink, ...) - Payload discriminado (Wasm | Native | Virtual | Legacy) - SomaSpec (namespaces, cgroups, rlimits, cpu_affinity) - Supervision (Restart con backoff, OneShot, Delegate) - genesis: Vec<Card> recursivo - Validación exhaustiva (label, self-dep, payload, rlimits, cgroup) Aditivo brahman: - flow: Flows con TypeRef discriminado (Primitive | Wit) - pin_to opcional como pista para el broker - Permissions enumerados (NetworkingPolicy, FsPolicy, IpcPolicy) - Lifecycle ortogonal (Daemon | Oneshot | Widget) - Priority de scheduling - TrustLevel derivado de Permissions (no declarado) - ResolvedCard con WitInterface opcional - extensions: HashMap para forward-compat Formatos: JSON canónico + TOML, auto-detectados por extensión de archivo. Tests: 8/8 incluyendo arje_seed_format_compatible que valida que el formato JSON existente de arje sigue parseando con defaults para los campos nuevos. ente-card original sigue intacto; los consumidores (ente-zero, etc.) migrarán en una pasada posterior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
//! `brahman-card` — Tarjeta de Presentación canónica de Brahman.
|
||||
//!
|
||||
//! Híbrida del `EntityCard` de arje (identidad ULID, capacidades tipadas,
|
||||
//! `Payload`/`SomaSpec`/`Supervision`/`genesis` recursivo) con flujos tipados,
|
||||
//! permisos enumerados explícitos y nivel de confianza derivado del modelo
|
||||
//! que veníamos diseñando en `core_protocol`. Una sola tarjeta sirve a:
|
||||
//!
|
||||
//! - **El Init** (encarnación): `payload` + `soma` + `supervision` + `genesis`.
|
||||
//! - **El Admin** (matching): `provides`/`requires` + `flow` + `permissions`.
|
||||
//! - **El runtime** (sandbox): `permissions` enumerados → seccomp / namespaces.
|
||||
//!
|
||||
//! Forward-compat: cualquier campo desconocido se preserva en `extensions`
|
||||
//! (raíz) o en `extra` (sub-secciones).
|
||||
//!
|
||||
//! Formatos soportados: JSON (canónico, compatible con arje) y TOML
|
||||
//! (humano-legible). Auto-detección por extensión.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
/// Versión del esquema de la Card.
|
||||
pub const CARD_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
/// Versión del protocolo Brahman.
|
||||
pub const PROTOCOL_VERSION: &str = "0.1.0";
|
||||
|
||||
/// Errores de parseo o validación de la Card.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CardError {
|
||||
#[error("schema version mismatch: got {got}, expected {expected}")]
|
||||
SchemaMismatch { got: u16, expected: u16 },
|
||||
#[error("label vacío")]
|
||||
EmptyLabel,
|
||||
#[error("label demasiado largo: {0} bytes (máx 256)")]
|
||||
LabelTooLong(usize),
|
||||
#[error("capacidad presente en provides Y requires: {0:?}")]
|
||||
SelfDependency(Capability),
|
||||
#[error("payload Native/Legacy con exec vacío")]
|
||||
EmptyExec,
|
||||
#[error("payload Wasm con sha256 sentinela (todo ceros)")]
|
||||
SentinelWasmHash,
|
||||
#[error("rlimit inválido: {0}")]
|
||||
InvalidRlimit(&'static str),
|
||||
#[error("cgroup weight fuera de [1,10000]: {0}")]
|
||||
InvalidCgroupWeight(&'static str),
|
||||
#[error("flujo {section}: nombre duplicado '{name}'")]
|
||||
DuplicateFlowName {
|
||||
section: &'static str,
|
||||
name: String,
|
||||
},
|
||||
#[error("JSON inválido: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("TOML inválido: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
#[error("E/S leyendo card: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("formato desconocido (extensiones esperadas: .json, .toml)")]
|
||||
UnknownFormat,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Card raíz
|
||||
// =====================================================================
|
||||
|
||||
/// Tarjeta de Presentación de un módulo Brahman.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Versión del esquema. Cambiar = romper compatibilidad del fractal.
|
||||
pub schema_version: u16,
|
||||
|
||||
/// Identidad opaca, única en el grafo del fractal.
|
||||
pub id: Ulid,
|
||||
|
||||
/// Ancestro del que esta Card desciende (genealogía).
|
||||
#[serde(default)]
|
||||
pub lineage: Option<Ulid>,
|
||||
|
||||
/// Nombre humano-legible. Único por convención, no por validación.
|
||||
pub label: String,
|
||||
|
||||
/// Capacidades del sistema que esta Card ofrece a otros.
|
||||
#[serde(default)]
|
||||
pub provides: BTreeSet<Capability>,
|
||||
|
||||
/// Capacidades que necesita resolver el Init antes de encarnarla.
|
||||
#[serde(default)]
|
||||
pub requires: BTreeSet<Capability>,
|
||||
|
||||
/// Permisos sandbox declarativos (más alto nivel que `Capability`).
|
||||
/// El Admin los compila a seccomp/namespaces/cgroups concretos.
|
||||
#[serde(default)]
|
||||
pub permissions: Permissions,
|
||||
|
||||
/// Spec runtime Linux (namespaces, cgroups, rlimits, cpu_affinity).
|
||||
#[serde(default)]
|
||||
pub soma: SomaSpec,
|
||||
|
||||
/// Qué encarnar: WASM, ELF nativo, virtual, o legacy con shims.
|
||||
pub payload: Payload,
|
||||
|
||||
/// Política de supervisión (restart con backoff, oneshot, delegada).
|
||||
pub supervision: Supervision,
|
||||
|
||||
/// Modelo de ejecución (eje ortogonal a `supervision`).
|
||||
#[serde(default)]
|
||||
pub lifecycle: Lifecycle,
|
||||
|
||||
/// Prioridad de scheduling.
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
|
||||
/// Contratos de flujo de datos: qué consume, qué produce.
|
||||
#[serde(default)]
|
||||
pub flow: Flows,
|
||||
|
||||
/// Hijas a instanciar inmediatamente al encarnar esta Card.
|
||||
#[serde(default)]
|
||||
pub genesis: Vec<Card>,
|
||||
|
||||
/// Campos desconocidos preservados intactos (forward-compat).
|
||||
#[serde(flatten, default)]
|
||||
pub extensions: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Capacidades — heredadas de arje, tipadas, no strings
|
||||
// =====================================================================
|
||||
|
||||
/// Capacidad del sistema. Identificadores tipados, no strings libres.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Capability {
|
||||
/// Provee un punto de montaje root para Cards hijas.
|
||||
FilesystemRoot,
|
||||
/// Acceso a una familia netlink del kernel.
|
||||
KernelNetlink(NetlinkFamily),
|
||||
/// Endpoint del bus interno — equivalente tipado de un nombre D-Bus.
|
||||
Endpoint {
|
||||
interface: InterfaceId,
|
||||
version: u16,
|
||||
},
|
||||
/// Reemplazo del shim de systemd-logind. Solo el ente compat lo provee.
|
||||
LegacyLogind,
|
||||
/// Acceso crudo a una clase de dispositivo. Capacidad escalada.
|
||||
Device { class: DeviceClass },
|
||||
/// Permiso de instanciar Cards hijas. Por defecto solo PID 1 lo tiene.
|
||||
Spawn,
|
||||
/// Acceso a logging estructurado del fractal.
|
||||
Journal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum NetlinkFamily {
|
||||
Uevent,
|
||||
Route,
|
||||
Generic,
|
||||
Audit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum DeviceClass {
|
||||
Block,
|
||||
Tty,
|
||||
Input,
|
||||
Drm,
|
||||
Net,
|
||||
Hidraw,
|
||||
}
|
||||
|
||||
/// Identificador de interfaz del bus interno (UUID, no string libre).
|
||||
/// Para extender el protocolo, se genera un UUID nuevo y se versiona.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct InterfaceId(pub [u8; 16]);
|
||||
|
||||
// =====================================================================
|
||||
// Permisos sandbox — más alto nivel que Capability
|
||||
// =====================================================================
|
||||
|
||||
/// Permisos declarativos. El Admin los traduce a seccomp/namespaces.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Permissions {
|
||||
#[serde(default)]
|
||||
pub networking: NetworkingPolicy,
|
||||
#[serde(default)]
|
||||
pub filesystem: FsPolicy,
|
||||
#[serde(default)]
|
||||
pub ipc: IpcPolicy,
|
||||
/// Capacidad de spawnear sub-procesos. Implica `TrustLevel::System`.
|
||||
#[serde(default)]
|
||||
pub processes: bool,
|
||||
#[serde(flatten, default)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum NetworkingPolicy {
|
||||
#[default]
|
||||
None,
|
||||
Loopback,
|
||||
Outbound,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FsPolicy {
|
||||
#[default]
|
||||
None,
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IpcPolicy {
|
||||
/// Protocolos IPC permitidos (p. ej. `"wit-v1"`, `"shm-v1"`).
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SomaSpec — runtime Linux (heredado de arje sin cambios)
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SomaSpec {
|
||||
pub namespaces: NamespaceSet,
|
||||
pub rlimits: ResourceLimits,
|
||||
pub cgroup: CgroupSpec,
|
||||
pub cpu_affinity: Option<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NamespaceSet {
|
||||
pub mount: bool,
|
||||
pub pid: bool,
|
||||
pub net: bool,
|
||||
pub uts: bool,
|
||||
pub ipc: bool,
|
||||
pub user: bool,
|
||||
pub cgroup: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ResourceLimits {
|
||||
pub mem_bytes: Option<u64>,
|
||||
pub nproc: Option<u32>,
|
||||
pub nofile: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CgroupSpec {
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
pub cpu_weight: Option<u32>,
|
||||
pub io_weight: Option<u32>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Payload — qué encarnar (heredado de arje)
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Payload {
|
||||
Wasm {
|
||||
module_sha256: [u8; 32],
|
||||
entry: String,
|
||||
},
|
||||
Native {
|
||||
exec: String,
|
||||
argv: Vec<String>,
|
||||
envp: Vec<(String, String)>,
|
||||
},
|
||||
/// Sin proceso. Nodo lógico del grafo (agregadores, mediators).
|
||||
Virtual,
|
||||
/// Wrapper de daemon legacy. `fakes` activa shims D-Bus / sd_notify.
|
||||
Legacy {
|
||||
exec: String,
|
||||
argv: Vec<String>,
|
||||
fakes: BTreeSet<LegacyFacade>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum LegacyFacade {
|
||||
SystemdLogind,
|
||||
SystemdHostnamed,
|
||||
SystemdNotify,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Supervisión (heredada de arje)
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Supervision {
|
||||
Restart {
|
||||
#[serde(with = "duration_millis")]
|
||||
initial: Duration,
|
||||
#[serde(with = "duration_millis")]
|
||||
max: Duration,
|
||||
},
|
||||
OneShot,
|
||||
Delegate,
|
||||
}
|
||||
|
||||
mod duration_millis {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_u64(d.as_millis() as u64)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
|
||||
let ms = u64::deserialize(d)?;
|
||||
Ok(Duration::from_millis(ms))
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lifecycle / Priority (del modelo brahman)
|
||||
// =====================================================================
|
||||
|
||||
/// Modelo de ejecución (rol). Ortogonal a `Supervision` (política de restart).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Lifecycle {
|
||||
/// Servicio de larga duración.
|
||||
#[default]
|
||||
Daemon,
|
||||
/// Una sola ejecución; sale al terminar su tarea.
|
||||
Oneshot,
|
||||
/// Componente UI gestionado por el motor de widgets.
|
||||
Widget,
|
||||
}
|
||||
|
||||
/// Prioridad de scheduling.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Priority {
|
||||
Low,
|
||||
#[default]
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Flujos tipados (del modelo brahman)
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Flows {
|
||||
#[serde(default)]
|
||||
pub input: Vec<Flow>,
|
||||
#[serde(default)]
|
||||
pub output: Vec<Flow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Flow {
|
||||
/// Nombre único dentro de su dirección.
|
||||
pub name: String,
|
||||
/// Tipo de los datos que viajan por el flujo.
|
||||
#[serde(rename = "type")]
|
||||
pub ty: TypeRef,
|
||||
/// Sugerencia de productor/consumidor concreto. El broker la respeta
|
||||
/// como pista; cae en matching por tipo si no es resoluble.
|
||||
#[serde(default)]
|
||||
pub pin_to: Option<String>,
|
||||
}
|
||||
|
||||
/// Referencia a un tipo, discriminada para distinguir primitivas de tipos WIT.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum TypeRef {
|
||||
/// Tipo primitivo del runtime.
|
||||
Primitive { name: String },
|
||||
/// Tipo declarado en un paquete WIT.
|
||||
Wit {
|
||||
package: String,
|
||||
#[serde(default)]
|
||||
interface: Option<String>,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// API: parseo y validación
|
||||
// =====================================================================
|
||||
|
||||
impl Card {
|
||||
/// Deserializa una Card desde JSON y valida.
|
||||
pub fn from_json(src: &str) -> Result<Self, CardError> {
|
||||
let c: Self = serde_json::from_str(src)?;
|
||||
c.validate()?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Deserializa una Card desde TOML y valida.
|
||||
pub fn from_toml(src: &str) -> Result<Self, CardError> {
|
||||
let c: Self = toml::from_str(src)?;
|
||||
c.validate()?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Carga una Card desde disco. Auto-detecta formato por extensión
|
||||
/// (`.json` o `.toml`).
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, CardError> {
|
||||
let p = path.as_ref();
|
||||
let src = std::fs::read_to_string(p)?;
|
||||
match p.extension().and_then(|e| e.to_str()) {
|
||||
Some("json") => Self::from_json(&src),
|
||||
Some("toml") => Self::from_toml(&src),
|
||||
_ => Err(CardError::UnknownFormat),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-serializa la Card a JSON con indentación.
|
||||
pub fn to_json_pretty(&self) -> Result<String, CardError> {
|
||||
Ok(serde_json::to_string_pretty(self)?)
|
||||
}
|
||||
|
||||
/// Validación semántica exhaustiva, recursiva sobre `genesis`.
|
||||
pub fn validate(&self) -> Result<(), CardError> {
|
||||
if self.schema_version != CARD_SCHEMA_VERSION {
|
||||
return Err(CardError::SchemaMismatch {
|
||||
got: self.schema_version,
|
||||
expected: CARD_SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if self.label.is_empty() {
|
||||
return Err(CardError::EmptyLabel);
|
||||
}
|
||||
if self.label.len() > 256 {
|
||||
return Err(CardError::LabelTooLong(self.label.len()));
|
||||
}
|
||||
for cap in &self.requires {
|
||||
if self.provides.contains(cap) {
|
||||
return Err(CardError::SelfDependency(cap.clone()));
|
||||
}
|
||||
}
|
||||
validate_payload(&self.payload)?;
|
||||
validate_rlimits(&self.soma.rlimits)?;
|
||||
validate_cgroup(&self.soma.cgroup)?;
|
||||
check_unique_flow_names(&self.flow.input, "flow.input")?;
|
||||
check_unique_flow_names(&self.flow.output, "flow.output")?;
|
||||
for child in &self.genesis {
|
||||
child.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_payload(p: &Payload) -> Result<(), CardError> {
|
||||
match p {
|
||||
Payload::Native { exec, .. } | Payload::Legacy { exec, .. } => {
|
||||
if exec.trim().is_empty() {
|
||||
return Err(CardError::EmptyExec);
|
||||
}
|
||||
}
|
||||
Payload::Wasm { module_sha256, .. } => {
|
||||
if module_sha256.iter().all(|&b| b == 0) {
|
||||
return Err(CardError::SentinelWasmHash);
|
||||
}
|
||||
}
|
||||
Payload::Virtual => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_rlimits(rl: &ResourceLimits) -> Result<(), CardError> {
|
||||
if let Some(m) = rl.mem_bytes {
|
||||
if m == 0 {
|
||||
return Err(CardError::InvalidRlimit("mem_bytes=0"));
|
||||
}
|
||||
if m > 1u64 << 40 {
|
||||
return Err(CardError::InvalidRlimit("mem_bytes>1TiB"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = rl.nproc {
|
||||
if n == 0 || n > 65535 {
|
||||
return Err(CardError::InvalidRlimit("nproc fuera de [1,65535]"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = rl.nofile {
|
||||
if n == 0 || n > 1_048_576 {
|
||||
return Err(CardError::InvalidRlimit("nofile fuera de [1,1M]"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_cgroup(cg: &CgroupSpec) -> Result<(), CardError> {
|
||||
if let Some(w) = cg.cpu_weight {
|
||||
if !(1..=10000).contains(&w) {
|
||||
return Err(CardError::InvalidCgroupWeight("cpu_weight"));
|
||||
}
|
||||
}
|
||||
if let Some(w) = cg.io_weight {
|
||||
if !(1..=10000).contains(&w) {
|
||||
return Err(CardError::InvalidCgroupWeight("io_weight"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_unique_flow_names(flows: &[Flow], section: &'static str) -> Result<(), CardError> {
|
||||
let mut seen = HashSet::new();
|
||||
for f in flows {
|
||||
if !seen.insert(f.name.as_str()) {
|
||||
return Err(CardError::DuplicateFlowName {
|
||||
section,
|
||||
name: f.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Trust derivado
|
||||
// =====================================================================
|
||||
|
||||
/// Nivel de confianza derivado de los permisos. **No es un campo declarado** —
|
||||
/// se calcula. Una sola fuente de verdad: lo que el Admin concede.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TrustLevel {
|
||||
/// Sin permisos — sandbox total.
|
||||
Untrusted,
|
||||
/// Permisos menores (loopback, FS read-only, IPC).
|
||||
Sandboxed,
|
||||
/// Permisos amplios (red saliente o FS read-write).
|
||||
Privileged,
|
||||
/// Capacidad de spawnear procesos.
|
||||
System,
|
||||
}
|
||||
|
||||
impl TrustLevel {
|
||||
/// Política de derivación:
|
||||
/// - `processes = true` ⇒ `System`.
|
||||
/// - FS `read-write` o networking `outbound`/`full` ⇒ `Privileged`.
|
||||
/// - FS `read-only`, networking `loopback`, o cualquier IPC ⇒ `Sandboxed`.
|
||||
/// - Sin permisos ⇒ `Untrusted`.
|
||||
pub fn derive(p: &Permissions) -> Self {
|
||||
if p.processes {
|
||||
return Self::System;
|
||||
}
|
||||
if matches!(p.filesystem, FsPolicy::ReadWrite)
|
||||
|| matches!(
|
||||
p.networking,
|
||||
NetworkingPolicy::Outbound | NetworkingPolicy::Full
|
||||
)
|
||||
{
|
||||
return Self::Privileged;
|
||||
}
|
||||
if matches!(p.filesystem, FsPolicy::ReadOnly)
|
||||
|| matches!(p.networking, NetworkingPolicy::Loopback)
|
||||
|| !p.ipc.allow.is_empty()
|
||||
{
|
||||
return Self::Sandboxed;
|
||||
}
|
||||
Self::Untrusted
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Identidad runtime (Card + WIT extraído + trust)
|
||||
// =====================================================================
|
||||
|
||||
/// Resumen de la interfaz WIT extraída del componente WASM/WIT.
|
||||
/// Vacío para módulos agnósticos (sin contrato WIT).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WitInterface {
|
||||
pub package: String,
|
||||
pub world: String,
|
||||
pub exports: Vec<String>,
|
||||
pub imports: Vec<String>,
|
||||
}
|
||||
|
||||
/// Card resuelta a runtime: schema + WIT opcional + trust derivado.
|
||||
/// Es lo que el Admin indexa.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedCard {
|
||||
pub card: Card,
|
||||
/// `Some` si el módulo es consciente (expone WIT), `None` si es agnóstico.
|
||||
pub wit: Option<WitInterface>,
|
||||
pub trust: TrustLevel,
|
||||
}
|
||||
|
||||
impl ResolvedCard {
|
||||
/// Construye una Card resuelta sin información WIT.
|
||||
pub fn from_agnostic(card: Card) -> Self {
|
||||
let trust = TrustLevel::derive(&card.permissions);
|
||||
Self {
|
||||
card,
|
||||
wit: None,
|
||||
trust,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye una Card resuelta con interfaz WIT extraída.
|
||||
pub fn from_conscious(card: Card, wit: WitInterface) -> Self {
|
||||
let trust = TrustLevel::derive(&card.permissions);
|
||||
Self {
|
||||
card,
|
||||
wit: Some(wit),
|
||||
trust,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_card_json() -> &'static str {
|
||||
r#"{
|
||||
"schema_version": 1,
|
||||
"id": "01HQAR53D4M2NBV8KZTYXFGS01",
|
||||
"lineage": null,
|
||||
"label": "brahman.semantic_dht",
|
||||
"provides": ["Spawn", "Journal"],
|
||||
"requires": [],
|
||||
"permissions": {
|
||||
"networking": "loopback",
|
||||
"filesystem": "read-only",
|
||||
"ipc": { "allow": ["wit-v1"] },
|
||||
"processes": false
|
||||
},
|
||||
"soma": {
|
||||
"namespaces": {
|
||||
"mount": false, "pid": false, "net": false,
|
||||
"uts": false, "ipc": false, "user": false, "cgroup": false
|
||||
},
|
||||
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
|
||||
"cgroup": { "path": "ente.slice/dht", "cpu_weight": null, "io_weight": null },
|
||||
"cpu_affinity": null
|
||||
},
|
||||
"payload": "Virtual",
|
||||
"supervision": { "Restart": { "initial": 100, "max": 30000 } },
|
||||
"lifecycle": "daemon",
|
||||
"priority": "high",
|
||||
"flow": {
|
||||
"input": [
|
||||
{ "name": "search-query", "type": { "kind": "primitive", "name": "string" } }
|
||||
],
|
||||
"output": [
|
||||
{ "name": "dht-results",
|
||||
"type": { "kind": "wit", "package": "brahman:dht", "name": "entity-result" } }
|
||||
]
|
||||
},
|
||||
"genesis": []
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_full_json() {
|
||||
let c = Card::from_json(sample_card_json()).unwrap();
|
||||
assert_eq!(c.label, "brahman.semantic_dht");
|
||||
assert_eq!(c.lifecycle, Lifecycle::Daemon);
|
||||
assert_eq!(c.priority, Priority::High);
|
||||
assert_eq!(c.permissions.filesystem, FsPolicy::ReadOnly);
|
||||
assert_eq!(c.permissions.networking, NetworkingPolicy::Loopback);
|
||||
assert_eq!(c.permissions.ipc.allow, vec!["wit-v1".to_string()]);
|
||||
assert_eq!(c.flow.input.len(), 1);
|
||||
assert_eq!(c.flow.output.len(), 1);
|
||||
match &c.flow.output[0].ty {
|
||||
TypeRef::Wit { package, name, .. } => {
|
||||
assert_eq!(package, "brahman:dht");
|
||||
assert_eq!(name, "entity-result");
|
||||
}
|
||||
_ => panic!("expected Wit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_preserves_shape() {
|
||||
let c1 = Card::from_json(sample_card_json()).unwrap();
|
||||
let s = c1.to_json_pretty().unwrap();
|
||||
let c2 = Card::from_json(&s).unwrap();
|
||||
assert_eq!(c1.label, c2.label);
|
||||
assert_eq!(c1.flow.input.len(), c2.flow.input.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_derivation() {
|
||||
let mut p = Permissions::default();
|
||||
assert_eq!(TrustLevel::derive(&p), TrustLevel::Untrusted);
|
||||
p.filesystem = FsPolicy::ReadOnly;
|
||||
assert_eq!(TrustLevel::derive(&p), TrustLevel::Sandboxed);
|
||||
p.networking = NetworkingPolicy::Outbound;
|
||||
assert_eq!(TrustLevel::derive(&p), TrustLevel::Privileged);
|
||||
p.processes = true;
|
||||
assert_eq!(TrustLevel::derive(&p), TrustLevel::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_flow_names_rejected() {
|
||||
let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
|
||||
c.flow.input.push(c.flow.input[0].clone());
|
||||
assert!(matches!(
|
||||
c.validate(),
|
||||
Err(CardError::DuplicateFlowName { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_dependency_rejected() {
|
||||
let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
|
||||
c.requires.insert(Capability::Spawn);
|
||||
assert!(matches!(c.validate(), Err(CardError::SelfDependency(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_genesis_propagates() {
|
||||
let parent_src = r#"{
|
||||
"schema_version": 1,
|
||||
"id": "01HQAR53D4M2NBV8KZTYXFGS01",
|
||||
"label": "parent",
|
||||
"provides": [], "requires": [],
|
||||
"soma": {
|
||||
"namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
|
||||
"rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
|
||||
"cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
|
||||
"cpu_affinity": null
|
||||
},
|
||||
"payload": "Virtual",
|
||||
"supervision": "OneShot",
|
||||
"genesis": [{
|
||||
"schema_version": 1,
|
||||
"id": "01HQAR53D4M2NBV8KZTYXFGS02",
|
||||
"label": "",
|
||||
"provides": [], "requires": [],
|
||||
"soma": {
|
||||
"namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
|
||||
"rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
|
||||
"cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
|
||||
"cpu_affinity": null
|
||||
},
|
||||
"payload": "Virtual",
|
||||
"supervision": "OneShot",
|
||||
"genesis": []
|
||||
}]
|
||||
}"#;
|
||||
assert!(matches!(
|
||||
Card::from_json(parent_src),
|
||||
Err(CardError::EmptyLabel)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_card_carries_derived_trust() {
|
||||
let c = Card::from_json(sample_card_json()).unwrap();
|
||||
let resolved = ResolvedCard::from_agnostic(c);
|
||||
assert_eq!(resolved.trust, TrustLevel::Sandboxed);
|
||||
assert!(resolved.wit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arje_seed_format_compatible() {
|
||||
// Reproduce el formato canónico de arje (sin lifecycle/priority/flow,
|
||||
// que son aditivos brahman). Debe parsear con defaults.
|
||||
let src = r#"{
|
||||
"schema_version": 1,
|
||||
"id": "01HQAR53D4M2NBV8KZTYXFGS01",
|
||||
"lineage": null,
|
||||
"label": "vps-min",
|
||||
"provides": ["Spawn", "Journal"],
|
||||
"requires": [],
|
||||
"soma": {
|
||||
"namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
|
||||
"rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
|
||||
"cgroup": {"path":"ente.slice/zero","cpu_weight":null,"io_weight":null},
|
||||
"cpu_affinity": null
|
||||
},
|
||||
"payload": "Virtual",
|
||||
"supervision": "OneShot",
|
||||
"genesis": []
|
||||
}"#;
|
||||
let c = Card::from_json(src).unwrap();
|
||||
assert_eq!(c.lifecycle, Lifecycle::Daemon); // default
|
||||
assert_eq!(c.priority, Priority::Normal); // default
|
||||
assert_eq!(c.flow.input.len(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user