⎇ 07d77a335f2adcd4dca2a6c685dfa6342294e61e
crates/core/brahman-card/src/lib.rs
Historia
Quién tocó qué
crudo
feat(handshake): integra el broker con el ciclo de sesiones
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}