tawasuyu

tawasuyu/brahman

Rust 405TOML 156Markdown 41JSON 175449 Definiciones

⎇ 07d77a335f2adcd4dca2a6c685dfa6342294e61e crates/core/brahman-card/src/lib.rs Rust · 826 líneas · 26.7 KB Historia Quién tocó qué crudo

feat(handshake): integra el broker con el ciclo de sesiones 07d77a335 · Sergio ·

1//! `brahman-card` — Tarjeta de Presentación canónica de Brahman.
2//!
3//! Híbrida del `EntityCard` de arje (identidad ULID, capacidades tipadas,
4//! `Payload`/`SomaSpec`/`Supervision`/`genesis` recursivo) con flujos tipados,
5//! permisos enumerados explícitos y nivel de confianza derivado del modelo
6//! que veníamos diseñando en `core_protocol`. Una sola tarjeta sirve a:
7//!
8//! - **El Init** (encarnación): `payload` + `soma` + `supervision` + `genesis`.
9//! - **El Admin** (matching): `provides`/`requires` + `flow` + `permissions`.
10//! - **El runtime** (sandbox): `permissions` enumerados → seccomp / namespaces.
11//!
12//! Forward-compat: cualquier campo desconocido se preserva en `extensions`
13//! (raíz) o en `extra` (sub-secciones).
14//!
15//! Formatos soportados: JSON (canónico, compatible con arje) y TOML
16//! (humano-legible). Auto-detección por extensión.
17
18#![forbid(unsafe_code)]
19#![warn(rust_2018_idioms)]
20
21use std::collections::{BTreeSet, HashSet};
22use std::path::Path;
23use std::time::Duration;
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use ulid::Ulid;
28
29/// Versión del esquema de la Card.
30pub const CARD_SCHEMA_VERSION: u16 = 1;
31
32/// Versión del protocolo Brahman.
33pub const PROTOCOL_VERSION: &str = "0.1.0";
34
35/// Errores de parseo o validación de la Card.
36#[derive(Debug, Error)]
37pub enum CardError {
38    #[error("schema version mismatch: got {got}, expected {expected}")]
39    SchemaMismatch { got: u16, expected: u16 },
40    #[error("label vacío")]
41    EmptyLabel,
42    #[error("label demasiado largo: {0} bytes (máx 256)")]
43    LabelTooLong(usize),
44    #[error("capacidad presente en provides Y requires: {0:?}")]
45    SelfDependency(Capability),
46    #[error("payload Native/Legacy con exec vacío")]
47    EmptyExec,
48    #[error("payload Wasm con sha256 sentinela (todo ceros)")]
49    SentinelWasmHash,
50    #[error("rlimit inválido: {0}")]
51    InvalidRlimit(&'static str),
52    #[error("cgroup weight fuera de [1,10000]: {0}")]
53    InvalidCgroupWeight(&'static str),
54    #[error("flujo {section}: nombre duplicado '{name}'")]
55    DuplicateFlowName {
56        section: &'static str,
57        name: String,
58    },
59    #[error("JSON inválido: {0}")]
60    Json(#[from] serde_json::Error),
61    #[error("TOML inválido: {0}")]
62    Toml(#[from] toml::de::Error),
63    #[error("E/S leyendo card: {0}")]
64    Io(#[from] std::io::Error),
65    #[error("formato desconocido (extensiones esperadas: .json, .toml)")]
66    UnknownFormat,
67}
68
69// =====================================================================
70// Card raíz
71// =====================================================================
72
73/// Tarjeta de Presentación de un módulo Brahman.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Card {
76    /// Versión del esquema. Cambiar = romper compatibilidad del fractal.
77    pub schema_version: u16,
78
79    /// Identidad opaca, única en el grafo del fractal.
80    pub id: Ulid,
81
82    /// Ancestro del que esta Card desciende (genealogía).
83    #[serde(default)]
84    pub lineage: Option<Ulid>,
85
86    /// Nombre humano-legible. Único por convención, no por validación.
87    pub label: String,
88
89    /// Capacidades del sistema que esta Card ofrece a otros.
90    #[serde(default)]
91    pub provides: BTreeSet<Capability>,
92
93    /// Capacidades que necesita resolver el Init antes de encarnarla.
94    #[serde(default)]
95    pub requires: BTreeSet<Capability>,
96
97    /// Permisos sandbox declarativos (más alto nivel que `Capability`).
98    /// El Admin los compila a seccomp/namespaces/cgroups concretos.
99    #[serde(default)]
100    pub permissions: Permissions,
101
102    /// Spec runtime Linux (namespaces, cgroups, rlimits, cpu_affinity).
103    #[serde(default)]
104    pub soma: SomaSpec,
105
106    /// Qué encarnar: WASM, ELF nativo, virtual, o legacy con shims.
107    pub payload: Payload,
108
109    /// Política de supervisión (restart con backoff, oneshot, delegada).
110    pub supervision: Supervision,
111
112    /// Modelo de ejecución (eje ortogonal a `supervision`).
113    #[serde(default)]
114    pub lifecycle: Lifecycle,
115
116    /// Prioridad de scheduling.
117    #[serde(default)]
118    pub priority: Priority,
119
120    /// Contratos de flujo de datos: qué consume, qué produce.
121    #[serde(default)]
122    pub flow: Flows,
123
124    /// Hijas a instanciar inmediatamente al encarnar esta Card.
125    #[serde(default)]
126    pub genesis: Vec<Card>,
127}
128
129impl Default for Card {
130    /// Default razonable para `..Default::default()` en struct-literals.
131    /// `id` queda en `Ulid::nil()` y `label` vacío — el consumidor debe
132    /// sobreescribirlos antes de validar.
133    fn default() -> Self {
134        Self {
135            schema_version: CARD_SCHEMA_VERSION,
136            id: Ulid::nil(),
137            lineage: None,
138            label: String::new(),
139            provides: BTreeSet::new(),
140            requires: BTreeSet::new(),
141            permissions: Permissions::default(),
142            soma: SomaSpec::default(),
143            payload: Payload::Virtual,
144            supervision: Supervision::OneShot,
145            lifecycle: Lifecycle::default(),
146            priority: Priority::default(),
147            flow: Flows::default(),
148            genesis: Vec::new(),
149        }
150    }
151}
152
153// =====================================================================
154// Capacidades — heredadas de arje, tipadas, no strings
155// =====================================================================
156
157/// Capacidad del sistema. Identificadores tipados, no strings libres.
158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
159pub enum Capability {
160    /// Provee un punto de montaje root para Cards hijas.
161    FilesystemRoot,
162    /// Acceso a una familia netlink del kernel.
163    KernelNetlink(NetlinkFamily),
164    /// Endpoint del bus interno — equivalente tipado de un nombre D-Bus.
165    Endpoint {
166        interface: InterfaceId,
167        version: u16,
168    },
169    /// Reemplazo del shim de systemd-logind. Solo el ente compat lo provee.
170    LegacyLogind,
171    /// Acceso crudo a una clase de dispositivo. Capacidad escalada.
172    Device { class: DeviceClass },
173    /// Permiso de instanciar Cards hijas. Por defecto solo PID 1 lo tiene.
174    Spawn,
175    /// Acceso a logging estructurado del fractal.
176    Journal,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
180pub enum NetlinkFamily {
181    Uevent,
182    Route,
183    Generic,
184    Audit,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188pub enum DeviceClass {
189    Block,
190    Tty,
191    Input,
192    Drm,
193    Net,
194    Hidraw,
195}
196
197/// Identificador de interfaz del bus interno (UUID, no string libre).
198/// Para extender el protocolo, se genera un UUID nuevo y se versiona.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
200pub struct InterfaceId(pub [u8; 16]);
201
202// =====================================================================
203// Permisos sandbox — más alto nivel que Capability
204// =====================================================================
205
206/// Permisos declarativos. El Admin los traduce a seccomp/namespaces.
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct Permissions {
209    #[serde(default)]
210    pub networking: NetworkingPolicy,
211    #[serde(default)]
212    pub filesystem: FsPolicy,
213    #[serde(default)]
214    pub ipc: IpcPolicy,
215    /// Capacidad de spawnear sub-procesos. Implica `TrustLevel::System`.
216    #[serde(default)]
217    pub processes: bool,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
221#[serde(rename_all = "kebab-case")]
222pub enum NetworkingPolicy {
223    #[default]
224    None,
225    Loopback,
226    Outbound,
227    Full,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
231#[serde(rename_all = "kebab-case")]
232pub enum FsPolicy {
233    #[default]
234    None,
235    ReadOnly,
236    ReadWrite,
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct IpcPolicy {
241    /// Protocolos IPC permitidos (p. ej. `"wit-v1"`, `"shm-v1"`).
242    #[serde(default)]
243    pub allow: Vec<String>,
244}
245
246// =====================================================================
247// SomaSpec — runtime Linux (heredado de arje sin cambios)
248// =====================================================================
249
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct SomaSpec {
252    pub namespaces: NamespaceSet,
253    pub rlimits: ResourceLimits,
254    pub cgroup: CgroupSpec,
255    pub cpu_affinity: Option<Vec<u32>>,
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct NamespaceSet {
260    pub mount: bool,
261    pub pid: bool,
262    pub net: bool,
263    pub uts: bool,
264    pub ipc: bool,
265    pub user: bool,
266    pub cgroup: bool,
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
270pub struct ResourceLimits {
271    pub mem_bytes: Option<u64>,
272    pub nproc: Option<u32>,
273    pub nofile: Option<u32>,
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277pub struct CgroupSpec {
278    #[serde(default)]
279    pub path: String,
280    pub cpu_weight: Option<u32>,
281    pub io_weight: Option<u32>,
282}
283
284// =====================================================================
285// Payload — qué encarnar (heredado de arje)
286// =====================================================================
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub enum Payload {
290    Wasm {
291        module_sha256: [u8; 32],
292        entry: String,
293    },
294    Native {
295        exec: String,
296        argv: Vec<String>,
297        envp: Vec<(String, String)>,
298    },
299    /// Sin proceso. Nodo lógico del grafo (agregadores, mediators).
300    Virtual,
301    /// Wrapper de daemon legacy. `fakes` activa shims D-Bus / sd_notify.
302    Legacy {
303        exec: String,
304        argv: Vec<String>,
305        fakes: BTreeSet<LegacyFacade>,
306    },
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
310pub enum LegacyFacade {
311    SystemdLogind,
312    SystemdHostnamed,
313    SystemdNotify,
314}
315
316// =====================================================================
317// Supervisión (heredada de arje)
318// =====================================================================
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub enum Supervision {
322    Restart {
323        #[serde(with = "duration_millis")]
324        initial: Duration,
325        #[serde(with = "duration_millis")]
326        max: Duration,
327    },
328    OneShot,
329    Delegate,
330}
331
332mod duration_millis {
333    use serde::{Deserialize, Deserializer, Serializer};
334    use std::time::Duration;
335
336    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
337        s.serialize_u64(d.as_millis() as u64)
338    }
339
340    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
341        let ms = u64::deserialize(d)?;
342        Ok(Duration::from_millis(ms))
343    }
344}
345
346// =====================================================================
347// Lifecycle / Priority (del modelo brahman)
348// =====================================================================
349
350/// Modelo de ejecución (rol). Ortogonal a `Supervision` (política de restart).
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
352#[serde(rename_all = "lowercase")]
353pub enum Lifecycle {
354    /// Servicio de larga duración.
355    #[default]
356    Daemon,
357    /// Una sola ejecución; sale al terminar su tarea.
358    Oneshot,
359    /// Componente UI gestionado por el motor de widgets.
360    Widget,
361}
362
363/// Prioridad de scheduling. Orden: `Low < Normal < High < Critical` —
364/// usable como tiebreaker en el broker (mayor priority gana).
365#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
366#[serde(rename_all = "lowercase")]
367pub enum Priority {
368    Low,
369    #[default]
370    Normal,
371    High,
372    Critical,
373}
374
375// =====================================================================
376// Flujos tipados (del modelo brahman)
377// =====================================================================
378
379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
380pub struct Flows {
381    #[serde(default)]
382    pub input: Vec<Flow>,
383    #[serde(default)]
384    pub output: Vec<Flow>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct Flow {
389    /// Nombre único dentro de su dirección.
390    pub name: String,
391    /// Tipo de los datos que viajan por el flujo.
392    #[serde(rename = "type")]
393    pub ty: TypeRef,
394    /// Sugerencia de productor/consumidor concreto. El broker la respeta
395    /// como pista; cae en matching por tipo si no es resoluble.
396    #[serde(default)]
397    pub pin_to: Option<String>,
398}
399
400/// Referencia a un tipo, discriminada para distinguir primitivas de tipos WIT.
401///
402/// **Wire format (JSON / TOML / postcard):** externally-tagged. Ejemplo JSON:
403/// ```json
404/// { "primitive": { "name": "string" } }
405/// { "wit": { "package": "brahman:dht", "name": "entity-result" } }
406/// ```
407/// Se eligió externally-tagged por compatibilidad con postcard, que no
408/// soporta `#[serde(tag = "...")]` (internally-tagged) en formatos no
409/// self-describing.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "lowercase")]
412pub enum TypeRef {
413    /// Tipo primitivo del runtime.
414    Primitive { name: String },
415    /// Tipo declarado en un paquete WIT.
416    Wit {
417        package: String,
418        #[serde(default)]
419        interface: Option<String>,
420        name: String,
421    },
422}
423
424// =====================================================================
425// API: parseo y validación
426// =====================================================================
427
428impl Card {
429    /// Deserializa una Card desde JSON y valida.
430    pub fn from_json(src: &str) -> Result<Self, CardError> {
431        let c: Self = serde_json::from_str(src)?;
432        c.validate()?;
433        Ok(c)
434    }
435
436    /// Deserializa una Card desde TOML y valida.
437    pub fn from_toml(src: &str) -> Result<Self, CardError> {
438        let c: Self = toml::from_str(src)?;
439        c.validate()?;
440        Ok(c)
441    }
442
443    /// Carga una Card desde disco. Auto-detecta formato por extensión
444    /// (`.json` o `.toml`).
445    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, CardError> {
446        let p = path.as_ref();
447        let src = std::fs::read_to_string(p)?;
448        match p.extension().and_then(|e| e.to_str()) {
449            Some("json") => Self::from_json(&src),
450            Some("toml") => Self::from_toml(&src),
451            _ => Err(CardError::UnknownFormat),
452        }
453    }
454
455    /// Re-serializa la Card a JSON con indentación.
456    pub fn to_json_pretty(&self) -> Result<String, CardError> {
457        Ok(serde_json::to_string_pretty(self)?)
458    }
459
460    /// Validación semántica exhaustiva, recursiva sobre `genesis`.
461    pub fn validate(&self) -> Result<(), CardError> {
462        if self.schema_version != CARD_SCHEMA_VERSION {
463            return Err(CardError::SchemaMismatch {
464                got: self.schema_version,
465                expected: CARD_SCHEMA_VERSION,
466            });
467        }
468        if self.label.is_empty() {
469            return Err(CardError::EmptyLabel);
470        }
471        if self.label.len() > 256 {
472            return Err(CardError::LabelTooLong(self.label.len()));
473        }
474        for cap in &self.requires {
475            if self.provides.contains(cap) {
476                return Err(CardError::SelfDependency(cap.clone()));
477            }
478        }
479        validate_payload(&self.payload)?;
480        validate_rlimits(&self.soma.rlimits)?;
481        validate_cgroup(&self.soma.cgroup)?;
482        check_unique_flow_names(&self.flow.input, "flow.input")?;
483        check_unique_flow_names(&self.flow.output, "flow.output")?;
484        for child in &self.genesis {
485            child.validate()?;
486        }
487        Ok(())
488    }
489}
490
491fn validate_payload(p: &Payload) -> Result<(), CardError> {
492    match p {
493        Payload::Native { exec, .. } | Payload::Legacy { exec, .. } => {
494            if exec.trim().is_empty() {
495                return Err(CardError::EmptyExec);
496            }
497        }
498        Payload::Wasm { module_sha256, .. } => {
499            if module_sha256.iter().all(|&b| b == 0) {
500                return Err(CardError::SentinelWasmHash);
501            }
502        }
503        Payload::Virtual => {}
504    }
505    Ok(())
506}
507
508fn validate_rlimits(rl: &ResourceLimits) -> Result<(), CardError> {
509    if let Some(m) = rl.mem_bytes {
510        if m == 0 {
511            return Err(CardError::InvalidRlimit("mem_bytes=0"));
512        }
513        if m > 1u64 << 40 {
514            return Err(CardError::InvalidRlimit("mem_bytes>1TiB"));
515        }
516    }
517    if let Some(n) = rl.nproc {
518        if n == 0 || n > 65535 {
519            return Err(CardError::InvalidRlimit("nproc fuera de [1,65535]"));
520        }
521    }
522    if let Some(n) = rl.nofile {
523        if n == 0 || n > 1_048_576 {
524            return Err(CardError::InvalidRlimit("nofile fuera de [1,1M]"));
525        }
526    }
527    Ok(())
528}
529
530fn validate_cgroup(cg: &CgroupSpec) -> Result<(), CardError> {
531    if let Some(w) = cg.cpu_weight {
532        if !(1..=10000).contains(&w) {
533            return Err(CardError::InvalidCgroupWeight("cpu_weight"));
534        }
535    }
536    if let Some(w) = cg.io_weight {
537        if !(1..=10000).contains(&w) {
538            return Err(CardError::InvalidCgroupWeight("io_weight"));
539        }
540    }
541    Ok(())
542}
543
544fn check_unique_flow_names(flows: &[Flow], section: &'static str) -> Result<(), CardError> {
545    let mut seen = HashSet::new();
546    for f in flows {
547        if !seen.insert(f.name.as_str()) {
548            return Err(CardError::DuplicateFlowName {
549                section,
550                name: f.name.clone(),
551            });
552        }
553    }
554    Ok(())
555}
556
557// =====================================================================
558// Trust derivado
559// =====================================================================
560
561/// Nivel de confianza derivado de los permisos. **No es un campo declarado** —
562/// se calcula. Una sola fuente de verdad: lo que el Admin concede.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
564pub enum TrustLevel {
565    /// Sin permisos — sandbox total.
566    Untrusted,
567    /// Permisos menores (loopback, FS read-only, IPC).
568    Sandboxed,
569    /// Permisos amplios (red saliente o FS read-write).
570    Privileged,
571    /// Capacidad de spawnear procesos.
572    System,
573}
574
575impl TrustLevel {
576    /// Política de derivación:
577    /// - `processes = true` ⇒ `System`.
578    /// - FS `read-write` o networking `outbound`/`full` ⇒ `Privileged`.
579    /// - FS `read-only`, networking `loopback`, o cualquier IPC ⇒ `Sandboxed`.
580    /// - Sin permisos ⇒ `Untrusted`.
581    pub fn derive(p: &Permissions) -> Self {
582        if p.processes {
583            return Self::System;
584        }
585        if matches!(p.filesystem, FsPolicy::ReadWrite)
586            || matches!(
587                p.networking,
588                NetworkingPolicy::Outbound | NetworkingPolicy::Full
589            )
590        {
591            return Self::Privileged;
592        }
593        if matches!(p.filesystem, FsPolicy::ReadOnly)
594            || matches!(p.networking, NetworkingPolicy::Loopback)
595            || !p.ipc.allow.is_empty()
596        {
597            return Self::Sandboxed;
598        }
599        Self::Untrusted
600    }
601}
602
603// =====================================================================
604// Identidad runtime (Card + WIT extraído + trust)
605// =====================================================================
606
607/// Resumen de la interfaz WIT extraída del componente WASM/WIT.
608/// Vacío para módulos agnósticos (sin contrato WIT).
609#[derive(Debug, Clone, Default)]
610pub struct WitInterface {
611    pub package: String,
612    pub world: String,
613    pub exports: Vec<String>,
614    pub imports: Vec<String>,
615}
616
617/// Card resuelta a runtime: schema + WIT opcional + trust derivado.
618/// Es lo que el Admin indexa.
619#[derive(Debug, Clone)]
620pub struct ResolvedCard {
621    pub card: Card,
622    /// `Some` si el módulo es consciente (expone WIT), `None` si es agnóstico.
623    pub wit: Option<WitInterface>,
624    pub trust: TrustLevel,
625}
626
627impl ResolvedCard {
628    /// Construye una Card resuelta sin información WIT.
629    pub fn from_agnostic(card: Card) -> Self {
630        let trust = TrustLevel::derive(&card.permissions);
631        Self {
632            card,
633            wit: None,
634            trust,
635        }
636    }
637
638    /// Construye una Card resuelta con interfaz WIT extraída.
639    pub fn from_conscious(card: Card, wit: WitInterface) -> Self {
640        let trust = TrustLevel::derive(&card.permissions);
641        Self {
642            card,
643            wit: Some(wit),
644            trust,
645        }
646    }
647}
648
649// =====================================================================
650// Tests
651// =====================================================================
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn sample_card_json() -> &'static str {
658        r#"{
659            "schema_version": 1,
660            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
661            "lineage": null,
662            "label": "brahman.semantic_dht",
663            "provides": ["Spawn", "Journal"],
664            "requires": [],
665            "permissions": {
666                "networking": "loopback",
667                "filesystem": "read-only",
668                "ipc": { "allow": ["wit-v1"] },
669                "processes": false
670            },
671            "soma": {
672                "namespaces": {
673                    "mount": false, "pid": false, "net": false,
674                    "uts": false, "ipc": false, "user": false, "cgroup": false
675                },
676                "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
677                "cgroup": { "path": "ente.slice/dht", "cpu_weight": null, "io_weight": null },
678                "cpu_affinity": null
679            },
680            "payload": "Virtual",
681            "supervision": { "Restart": { "initial": 100, "max": 30000 } },
682            "lifecycle": "daemon",
683            "priority": "high",
684            "flow": {
685                "input": [
686                    { "name": "search-query", "type": { "primitive": { "name": "string" } } }
687                ],
688                "output": [
689                    { "name": "dht-results",
690                      "type": { "wit": { "package": "brahman:dht", "name": "entity-result" } } }
691                ]
692            },
693            "genesis": []
694        }"#
695    }
696
697    #[test]
698    fn parses_full_json() {
699        let c = Card::from_json(sample_card_json()).unwrap();
700        assert_eq!(c.label, "brahman.semantic_dht");
701        assert_eq!(c.lifecycle, Lifecycle::Daemon);
702        assert_eq!(c.priority, Priority::High);
703        assert_eq!(c.permissions.filesystem, FsPolicy::ReadOnly);
704        assert_eq!(c.permissions.networking, NetworkingPolicy::Loopback);
705        assert_eq!(c.permissions.ipc.allow, vec!["wit-v1".to_string()]);
706        assert_eq!(c.flow.input.len(), 1);
707        assert_eq!(c.flow.output.len(), 1);
708        match &c.flow.output[0].ty {
709            TypeRef::Wit { package, name, .. } => {
710                assert_eq!(package, "brahman:dht");
711                assert_eq!(name, "entity-result");
712            }
713            _ => panic!("expected Wit"),
714        }
715    }
716
717    #[test]
718    fn json_roundtrip_preserves_shape() {
719        let c1 = Card::from_json(sample_card_json()).unwrap();
720        let s = c1.to_json_pretty().unwrap();
721        let c2 = Card::from_json(&s).unwrap();
722        assert_eq!(c1.label, c2.label);
723        assert_eq!(c1.flow.input.len(), c2.flow.input.len());
724    }
725
726    #[test]
727    fn trust_derivation() {
728        let mut p = Permissions::default();
729        assert_eq!(TrustLevel::derive(&p), TrustLevel::Untrusted);
730        p.filesystem = FsPolicy::ReadOnly;
731        assert_eq!(TrustLevel::derive(&p), TrustLevel::Sandboxed);
732        p.networking = NetworkingPolicy::Outbound;
733        assert_eq!(TrustLevel::derive(&p), TrustLevel::Privileged);
734        p.processes = true;
735        assert_eq!(TrustLevel::derive(&p), TrustLevel::System);
736    }
737
738    #[test]
739    fn duplicate_flow_names_rejected() {
740        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
741        c.flow.input.push(c.flow.input[0].clone());
742        assert!(matches!(
743            c.validate(),
744            Err(CardError::DuplicateFlowName { .. })
745        ));
746    }
747
748    #[test]
749    fn self_dependency_rejected() {
750        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
751        c.requires.insert(Capability::Spawn);
752        assert!(matches!(c.validate(), Err(CardError::SelfDependency(_))));
753    }
754
755    #[test]
756    fn invalid_genesis_propagates() {
757        let parent_src = r#"{
758            "schema_version": 1,
759            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
760            "label": "parent",
761            "provides": [], "requires": [],
762            "soma": {
763                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
764                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
765                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
766                "cpu_affinity": null
767            },
768            "payload": "Virtual",
769            "supervision": "OneShot",
770            "genesis": [{
771                "schema_version": 1,
772                "id": "01HQAR53D4M2NBV8KZTYXFGS02",
773                "label": "",
774                "provides": [], "requires": [],
775                "soma": {
776                    "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
777                    "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
778                    "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
779                    "cpu_affinity": null
780                },
781                "payload": "Virtual",
782                "supervision": "OneShot",
783                "genesis": []
784            }]
785        }"#;
786        assert!(matches!(
787            Card::from_json(parent_src),
788            Err(CardError::EmptyLabel)
789        ));
790    }
791
792    #[test]
793    fn presentation_card_carries_derived_trust() {
794        let c = Card::from_json(sample_card_json()).unwrap();
795        let resolved = ResolvedCard::from_agnostic(c);
796        assert_eq!(resolved.trust, TrustLevel::Sandboxed);
797        assert!(resolved.wit.is_none());
798    }
799
800    #[test]
801    fn arje_seed_format_compatible() {
802        // Reproduce el formato canónico de arje (sin lifecycle/priority/flow,
803        // que son aditivos brahman). Debe parsear con defaults.
804        let src = r#"{
805            "schema_version": 1,
806            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
807            "lineage": null,
808            "label": "vps-min",
809            "provides": ["Spawn", "Journal"],
810            "requires": [],
811            "soma": {
812                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
813                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
814                "cgroup": {"path":"ente.slice/zero","cpu_weight":null,"io_weight":null},
815                "cpu_affinity": null
816            },
817            "payload": "Virtual",
818            "supervision": "OneShot",
819            "genesis": []
820        }"#;
821        let c = Card::from_json(src).unwrap();
822        assert_eq!(c.lifecycle, Lifecycle::Daemon); // default
823        assert_eq!(c.priority, Priority::Normal); // default
824        assert_eq!(c.flow.input.len(), 0);
825    }
826}