feat: card standalone — primitiva de identidad soberana (hoja, autocontenida)

Tarjeta de Presentación canónica: identidad arje + flujos tipados brahman,
content-addressed. Hoja pura sin deps internas — la base sobre la que se
montan red (chasqui/minga) y escritorio. cargo check pasa (3 crates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:05:09 +00:00
commit e6d21c6027
15 changed files with 6739 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
Generated
+3933
View File
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
# Cargo.toml raíz STANDALONE de card — front-door sobre Llimphi.
# Solo el código de card; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
[workspace]
resolver = "2"
members = [
"shared/card/card-core",
"shared/card/card-net",
"shared/card/card-wit",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/card"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
# card
> The canonical **presentation card** — sovereign identity + typed flows, in Rust.
`card` is the suite's identity primitive: a content-addressed *Tarjeta de Presentación* that binds an `arje` identity to typed `brahman` flows. It is the leaf that the networking and desktop layers build on — peers, channels and capabilities are all expressed in terms of cards.
## Crates
- **`card-core`** — the canonical card type (identity + typed flows), content-addressed.
- **`card-wit`** — WIT-typed flow descriptions (component-model interfaces).
## How dependencies work
A true leaf: `card` has **no internal dependencies** — only crates.io. It compiles standalone and is git-depended by the rest of the suite (networking via `chasqui`/`minga`, the desktop, etc.).
## License
MIT.
+36
View File
@@ -0,0 +1,36 @@
# card — identidad agnóstica de transporte
Contrato de identidad y membresía **independiente del transporte**: claves
Ed25519, `EspinaId` (hash de la clave pública), firmas y handshake de membresía
de una "espina" (red privada). El mismo contrato lo implementan dos transportes
distintos: `card-net` (libp2p) y `wawa-akasha` (protocolo propio de wawa).
## Subcrates
- **`card-core`** — el contrato agnóstico real: par Ed25519 → `EspinaId`,
`Card` firmada, `handshake` de membresía (un miembro presenta su tarjeta
firmada; el anfitrión la verifica contra la raíz de confianza de la espina).
Sólo bytes firmados — no sabe de libp2p ni Akasha.
- **`card-net`** — espina dorsal P2P sobre **libp2p**: discovery (mDNS +
Kademlia DHT), gossipsub, NAT traversal (Circuit Relay v2 + DCUtR + AutoNAT).
Implementa el contrato de `card-core` sobre libp2p. Lo consume `khipu`.
- **`card-wit`** — **[DORMIDO]** binding WIT/wasm del contrato. Se reactiva
cuando `card` cruce a apps WASM; hoy el contrato real es `card-core`.
## Estado (2026-05-31)
### Hecho
- `card-core`: identidad Ed25519 + `EspinaId` + handshake de membresía (contrato
agnóstico declarado como la fuente de verdad).
- `card-net`: discovery (mDNS+DHT), gossipsub y NAT traversal completo
(Relay v2 + DCUtR + AutoNAT); discovery de personas por `DhtKey::Persona`.
### Pendiente
- `card-wit`: dormido — binding WASM pendiente de reactivación.
- Espejo del contrato sobre `wawa-akasha` (transporte wawa) aún por cablear.
- Endurecer revocación / rotación de membresía de espina.
## Lugar en el repo
`shared/card` — contrato de identidad. `card-net` lo lleva a libp2p (khipu);
`agora` cubre firma/confianza de más alto nivel.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "card-core"
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
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "card-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 }
blake3 = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
+167
View File
@@ -0,0 +1,167 @@
//! Claves namespaced del DHT compartido — la **primitiva unificadora** de
//! Brahman (Capa 1).
//!
//! El ecosistema brahman corre UN solo Kademlia (en este crate, `card-net`).
//! Para que distintos dominios — código indexado (minga), Cards
//! (card-discovery), Personas (ágora), servicios — coexistan sin colisión,
//! cada clave lleva un byte de `kind` como prefijo. La representación en wire
//! es de longitud fija: `[kind_tag] ++ blake3(id)` = 33 bytes.
//!
//! Vive en `card-net` (y no en un dominio concreto) precisamente porque es el
//! namespace COMÚN: minga, agora y card-discovery la comparten. `minga-dht`
//! la re-exporta por compatibilidad histórica.
use serde::{Deserialize, Serialize};
/// Tipo de registro — el namespace de una clave en el DHT.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecordKind {
/// Bloque de código indexado (minga).
Code,
/// `Card` brahman (módulo, ente, etc.).
Card,
/// `Persona` de ágora (identidad humana federada).
Persona,
/// Endpoint de servicio.
Service,
/// Dominio definido por el consumidor.
Custom(u8),
}
impl RecordKind {
/// Byte de etiqueta. `Custom(n)` ocupa `0x80 | n` (top bit) para no
/// chocar nunca con los kinds estándar (`0x00..`).
pub fn tag(&self) -> u8 {
match self {
RecordKind::Code => 0x01,
RecordKind::Card => 0x02,
RecordKind::Persona => 0x03,
RecordKind::Service => 0x04,
RecordKind::Custom(n) => 0x80 | (n & 0x7f),
}
}
}
/// Longitud fija de la clave en wire: 1 byte de kind + 32 de hash.
pub const DHT_KEY_LEN: usize = 33;
/// Clave de DHT namespaced. Dos formas de construcción:
///
/// - Con un `id` legible (`new`, `code`, `card`, `persona`) — el wire
/// hashea el id con blake3. Útil cuando el consumidor publica
/// identidades simbólicas (nombres de módulos, slugs de personas).
/// - Con `for_hash` — el wire usa los 32 bytes del hash directamente.
/// Útil cuando el id YA es un blake3 (como en minga, que indexa
/// contenido por su α-hash, o ágora, cuyo `IdentityId` ya es
/// `blake3(pubkey)`) — evita una segunda pasada de blake3.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DhtKey {
kind: RecordKind,
/// Representación canónica de los 32 bytes que forman la "id" en wire.
/// Si se construyó por nombre simbólico, son `blake3(id_string)`.
/// Si se construyó por hash directo, son el hash tal cual.
body: [u8; 32],
/// `id` legible — para `Display`. `None` si se construyó por hash.
label: Option<String>,
}
impl DhtKey {
pub fn new(kind: RecordKind, id: impl Into<String>) -> Self {
let id = id.into();
let body = *blake3::hash(id.as_bytes()).as_bytes();
Self {
kind,
body,
label: Some(id),
}
}
/// Clave para un bloque de código (id legible).
pub fn code(id: impl Into<String>) -> Self {
Self::new(RecordKind::Code, id)
}
/// Clave para una Card.
pub fn card(id: impl Into<String>) -> Self {
Self::new(RecordKind::Card, id)
}
/// Clave para una Persona.
pub fn persona(id: impl Into<String>) -> Self {
Self::new(RecordKind::Persona, id)
}
/// Clave a partir de un hash ya computado (32 bytes). El wire usa
/// esos bytes directamente, **sin re-hashear**.
pub fn for_hash(kind: RecordKind, hash: [u8; 32]) -> Self {
Self {
kind,
body: hash,
label: None,
}
}
pub fn kind(&self) -> RecordKind {
self.kind
}
pub fn id(&self) -> Option<&str> {
self.label.as_deref()
}
/// Representación en wire: `[kind_tag] ++ body`, 33 bytes.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(DHT_KEY_LEN);
out.push(self.kind.tag());
out.extend_from_slice(&self.body);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_key_has_fixed_length() {
assert_eq!(DhtKey::card("modulo-x").to_bytes().len(), DHT_KEY_LEN);
assert_eq!(DhtKey::code("fn-hash").to_bytes().len(), DHT_KEY_LEN);
}
#[test]
fn same_id_different_kind_does_not_collide() {
let a = DhtKey::card("foo").to_bytes();
let b = DhtKey::code("foo").to_bytes();
let c = DhtKey::persona("foo").to_bytes();
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
// El hash del id es el mismo; sólo difiere el byte de kind.
assert_eq!(a[1..], b[1..]);
assert_ne!(a[0], b[0]);
}
#[test]
fn same_kind_and_id_is_stable() {
assert_eq!(DhtKey::card("x").to_bytes(), DhtKey::card("x").to_bytes());
}
#[test]
fn for_hash_no_rehashea() {
// for_hash debe usar los bytes tal cual: body = hash, no blake3(hash).
let h = [7u8; 32];
let k = DhtKey::for_hash(RecordKind::Persona, h);
let wire = k.to_bytes();
assert_eq!(wire[0], RecordKind::Persona.tag());
assert_eq!(&wire[1..], &h);
}
#[test]
fn custom_kind_never_collides_with_standard() {
for std in [RecordKind::Code, RecordKind::Card, RecordKind::Persona, RecordKind::Service] {
for n in 0..=127u8 {
assert_ne!(std.tag(), RecordKind::Custom(n).tag());
}
}
}
}
+518
View File
@@ -0,0 +1,518 @@
//! `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::{
autonat, dcutr, identify, identity, kad, mdns, noise, relay,
swarm::{behaviour::toggle::Toggle, 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 mod key;
pub use key::{DhtKey, RecordKind, DHT_KEY_LEN};
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,
/// Relay server (Circuit Relay v2): un nodo alcanzable presta su
/// conexión para que dos pares detrás de NAT se contacten. Pasivo —
/// sólo actúa ante reservas/solicitudes de circuito.
relay: relay::Behaviour,
/// Relay client: permite reservar un circuito en un relay y ser
/// alcanzable vía `…/p2p/<relay>/p2p-circuit/p2p/<self>`.
relay_client: relay::client::Behaviour,
/// DCUtR: tras conectar por relay, intenta promover a conexión
/// directa (hole-punching) coordinando por el circuito.
dcutr: dcutr::Behaviour,
/// AutoNAT: confirma qué direcciones externas son realmente alcanzables
/// pidiéndole a otros peers que nos disquen de vuelta. Sustituye el
/// "confiar a ciegas en el observed_addr de identify" por direcciones
/// verificadas — sólo esas se anuncian (y entran en reservas de relay).
autonat: autonat::Behaviour,
/// mDNS: descubrimiento de pares en la MISMA LAN sin bootstrap ni IP
/// conocida (multicast 224.0.0.251). Los pares descubiertos se inyectan
/// a la routing table de Kad (igual que las listen-addrs de identify), así
/// el DHT y los protocolos de stream funcionan zero-config en LAN.
/// `Toggle`: si el socket multicast no se puede abrir (sandbox, red sin
/// multicast), queda deshabilitado y el nodo sigue andando igual.
mdns: Toggle<mdns::tokio::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.
/// `card_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}")))?
// Inyecta el transporte de relay-client: habilita marcar y
// escuchar direcciones `…/p2p-circuit`. Provee el
// `relay_client` behaviour al closure de abajo.
.with_relay_client(noise::Config::new, yamux::Config::default)
.map_err(|e| NodeError::Build(format!("{e}")))?
.with_behaviour(|key, relay_client| {
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"))),
);
// mDNS resiliente: si el socket multicast no abre, seguimos sin
// descubrimiento LAN en vez de fallar el nodo entero.
let depurar = std::env::var("BRAHMAN_DEBUG").is_ok();
let mdns = match mdns::tokio::Behaviour::new(mdns::Config::default(), local) {
Ok(b) => {
if depurar {
eprintln!("brahman: mDNS ACTIVO (descubrimiento LAN)");
}
Toggle::from(Some(b))
}
Err(e) => {
if depurar {
eprintln!("brahman: mDNS DESACTIVADO ({e})");
}
Toggle::from(None)
}
};
BrahmanBehaviour {
block_list: allow_block_list::Behaviour::default(),
stream: stream::Behaviour::new(),
kad,
identify,
relay: relay::Behaviour::new(local, Default::default()),
relay_client,
dcutr: dcutr::Behaviour::new(local),
mdns,
autonat: autonat::Behaviour::new(
local,
autonat::Config {
// Confirmamos también IPs privadas/loopback: la
// malla Brahman vive tanto en LAN como en WAN, no
// sólo en IPs globales (default `true` las
// ignoraría y nada se confirmaría en LAN).
only_global_ips: false,
// Sondeo pronto tras arrancar (default ~15 s es
// demasiado para el flujo de reservas de relay).
boot_delay: Duration::from_secs(2),
retry_interval: Duration::from_secs(5),
..Default::default()
},
),
}
})
.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, .. }
)) => {
// El observed_addr que nos reporta identify se
// emite como CANDIDATO a externa; AutoNAT lo
// prueba pidiendo dial-backs y, si es
// alcanzable, dispara StatusChanged(Public) —
// ahí recién lo confirmamos (abajo). Las
// listen-addrs del peer pueblan la routing
// table de Kad.
for addr in info.listen_addrs {
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
}
}
// mDNS descubrió pares en la LAN: inyectamos sus
// direcciones a Kad (igual que identify). Con eso el
// DHT y los streams funcionan sin bootstrap manual.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Mdns(
mdns::Event::Discovered(list)
)) => {
for (peer_id, addr) in list {
if std::env::var("BRAHMAN_DEBUG").is_ok() {
eprintln!("brahman: mDNS descubrió {peer_id} en {addr}");
}
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
}
}
// AutoNAT confirmó (vía dial-back de otros peers)
// que somos alcanzables en `addr`: recién entonces
// la anunciamos como externa — la usa el relay
// server en las reservas y la ven los pares.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Autonat(
autonat::Event::StatusChanged { new: autonat::NatStatus::Public(addr), .. }
)) => {
swarm.add_external_address(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()
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "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]
card-core = { path = "../card-core" }
wit-parser = "0.230"
thiserror = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
[[example]]
name = "brahman-wit-info"
path = "examples/brahman-wit-info.rs"
+34
View File
@@ -0,0 +1,34 @@
# brahman-card-wit
> **DORMIDO (2026-05-30).** Capa 3 de Brahman. Ver `/BRAHMAN.md`.
Parser opcional de contratos WIT (`.wit` texto → `Vec<card_core::WitInterface>`, uno por `world`),
sobre `wit-parser` (sin `wasm-tools`/`wit-component`).
## Estado: relegado, no borrado
La visión original de Brahman incluía **módulos agnósticos descritos por interfaz WIT** (eventualmente WASM).
Esa capa **nunca se ejecutó**:
- No existe ningún archivo `.wit` en el workspace.
- Ningún crate de producción depende de este crate — sólo `examples/brahman-wit-info.rs` y la
**dev-dependency** de `card-sidecar`.
Se conserva (funciona, 210 LOC, reversible) por si algún día aparecen `.wit` reales. **No asumir que está
en ninguna ruta de build.**
## El contrato agnóstico real y vigente
```
shared/card (formato Card) + card-handshake (handshake nativo Rust) + DhtKey (namespacing en la DHT)
```
El tipo de metadata `WitInterface` vive en **`card-core`** (no aquí) y **sí** lo usa el broker
(`chasqui-broker`) para matching estructural — es metadata opcional viva. Lo único dormido es *este parser*
de archivos `.wit` inexistentes.
## Si se decide revivir WIT
Requeriría: (1) que el Init lea `<modulo>/wit/protocol.wit` en el descubrimiento y construya
`ResolvedCard::from_conscious(card, wit)`; (2) que algún módulo de producción publique un `.wit`.
Hasta entonces, este crate es tooling latente (`cargo run -p card-wit --example brahman-wit-info -- <archivo.wit>`).
@@ -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 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
}
+178
View File
@@ -0,0 +1,178 @@
//! `brahman-card-wit` — extractor de contratos WIT.
//!
//! **DORMIDO (2026-05-30).** No existe ningún archivo `.wit` en el workspace
//! y ningún crate de producción depende de éste (sólo `examples/` y la
//! dev-dependency de `card-sidecar`). Es la "Capa 3" de Brahman (ver `/BRAHMAN.md`):
//! la idea de módulos agnósticos descritos por interfaz WIT nunca se ejecutó.
//! El **contrato agnóstico real y vigente** es `shared/card` (formato `Card`) +
//! el handshake nativo Rust de `card-handshake` + el namespacing de `DhtKey`.
//! Se conserva como herramienta opcional por si algún día aparecen `.wit`
//! reales; no asumir que está en ninguna ruta de build.
//!
//! Nota: el tipo de metadata [`WitInterface`] vive en `card-core` y SÍ lo usa
//! el broker para matching estructural — eso es metadata opcional viva. Lo
//! dormido es este *parser* de archivos `.wit` inexistentes.
//!
//! 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 [`card_core::Card`]
//! cuando se construye una [`card_core::ResolvedCard`].
//!
//! Casos de uso (hipotéticos hasta que existan `.wit`):
//!
//! - 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 card_core::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());
}
}