feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)

Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay
+ Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se
consume por git desde su repo publicado; las hojas compartidas (format,
auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van
vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el
compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:01:49 +00:00
commit 3dc85ebdcd
116 changed files with 31060 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "app-bus"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "app-bus — registro único de aplicaciones de gioser + protocolo de menú global (Archivo/Editar/Ayuda) + bus de eventos foco/lanzamiento + trait Launcher. Lo consultan los launchers (mirada, shuma, wawa) en vez de reimplementar el despacho cada uno. Los datos + el trait son no_std; el descubrimiento (fs/TOML), el spawn de procesos y el Bus van detrás del feature `std`."
[features]
default = ["std"]
# `std` enciende el descubrimiento por filesystem/TOML, el spawn de
# procesos del host (ProcessLauncher) y el Bus pub/sub (std::sync::mpsc).
# Sin `std`, el crate queda en datos + trait Launcher + AppMenu, listo para
# espejar en el kernel de wawa.
std = ["serde/std", "toml", "directories"]
[dependencies]
# serde directo (no workspace) para poder apagar default-features en el
# build no_std — la versión workspace fuerza std.
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
toml = { workspace = true, optional = true }
directories = { workspace = true, optional = true }
[dev-dependencies]
serde_json = { workspace = true }
+38
View File
@@ -0,0 +1,38 @@
# app-bus — bus de eventos in-proc para apps Llimphi
Bus de **publish/subscribe** tipado, síncrono y en memoria, para coordinar apps
dentro del mismo proceso. Cubre tres familias de eventos transversales: **foco**
(qué app/ventana lo tiene), **navegación** (abrir/cerrar/enfocar una app o ruta)
y **notificaciones** efímeras.
## Qué expone
- `AppBus` — handle compartible (`Clone`, internamente `Arc<RwLock<…>>`).
- `Event` — enum transversal: `FocusChanged` / `Navigate` / `CloseApp` / `Notify`.
- `NotifyLevel``Info` / `Warn` / `Error`.
- `Subscription` — guard RAII: al soltarlo se cancela la suscripción.
- `publish(Event)` entrega de forma síncrona a todos los suscriptores; entrega
anidada si un callback publica desde su propio handler.
## No-objetivos
- No es un bus interproceso ni de red (eso es Akasha / app-channel).
- No persiste eventos ni garantiza entrega tras reinicio.
- No ordena por prioridad.
## Estado (2026-05-31)
### Hecho
- Bus pub/sub completo: `publish`, `subscribe`, cancelación por guard RAII.
- Enum `Event` con foco/navegación/cierre/notificaciones.
- Consumido por `launcher-llimphi` (dispara navegación/lanzamiento).
### Pendiente
- Adaptador multiproceso (hoy estrictamente in-proc).
- Entrega diferida / cola (hoy reentrancia anidada síncrona).
- Filtrado por tipo de evento en `subscribe` (hoy el callback filtra).
## Lugar en el repo
`shared/app-bus` — canal in-proc de grano fino. El plano de control a más alto
nivel es `shared/sandokan` (ver su SDD.md).
+32
View File
@@ -0,0 +1,32 @@
# Reproductor multimedia de la suite (02_ruway/media).
#
# Copiá este archivo a ~/.config/gioser/apps/media.toml para que los launchers
# (el menú de inicio de pata, el spotlight) y el "abrir con" del navegador lo
# descubran. El binario `media-app` debe estar en el PATH (cargo install) o poné
# su ruta absoluta en `exec`.
id = "media"
label = "Media"
icon = "▶"
category = "ruway"
# Los mimes que sabe abrir (open-with). Deben coincidir con los que pata deriva
# de la extensión (02_ruway/pata/pata-llimphi/src/open.rs::mime_for_path).
handles = [
"video/mp4",
"video/x-matroska",
"video/webm",
"video/quicktime",
"video/x-msvideo",
"audio/mpeg",
"audio/flac",
"audio/wav",
"audio/ogg",
"audio/opus",
]
[launch]
exec = "media-app"
# `%f` = la ruta del archivo a abrir (convención freedesktop). Sin placeholder,
# la ruta se agrega igual como último argumento.
args = ["%f"]
+40
View File
@@ -0,0 +1,40 @@
# Editor de texto/código de la suite (02_ruway/nada): file tree + text-editor
# Llimphi sobre archivos reales. Acepta una ruta (archivo o directorio) como
# argumento.
#
# Copiá este archivo a ~/.config/gioser/apps/nada.toml. El binario `nada` debe
# estar en el PATH (cargo install -p nada) o poné su ruta absoluta en `exec`.
id = "nada"
label = "Nada"
icon = "✎"
category = "ruway"
# Texto y código: lo que el text-editor de Llimphi sabe editar. Coincide con la
# tabla de mimes de pata (open.rs::mime_for_path).
handles = [
"text/plain",
"text/markdown",
"text/x-rst",
"text/csv",
"text/x-rust",
"text/x-python",
"text/javascript",
"text/typescript",
"text/x-c",
"text/x-c++",
"text/x-go",
"text/x-java",
"text/x-ruby",
"application/x-shellscript",
"application/toml",
"application/json",
"application/yaml",
"application/xml",
"text/html",
"text/css",
]
[launch]
exec = "nada"
args = ["%f"]
+795
View File
@@ -0,0 +1,795 @@
//! `app-bus` — el cimiento del menú de aplicaciones de gioser.
//!
//! Hoy hay tres lanzadores que no comparten nada: `mirada-launcher`
//! (TOML propio, `std::process`), `shuma-module-launcher` (otro TOML,
//! `process_group`) y el launcher in-kernel de wawa (Manifiesto, WASM).
//! Cada uno reimplementa "qué apps existen y cómo se lanzan". Este crate
//! es la tabla única que todos consultan.
//!
//! Cuatro piezas, en capas:
//!
//! 1. **Registro** ([`AppRegistry`] + [`AppEntry`]): qué apps hay, cómo se
//! lanzan ([`Launch`]) y qué mimes/lentes saben abrir (open-with).
//! Se descubre de `~/.config/gioser/apps/*.toml` (feature `std`).
//! 2. **Menú global** ([`AppMenu`]/[`Menu`]/[`MenuItem`]): el clásico
//! Archivo/Editar/Ayuda que la app *declara*. Cuando hay una barra de
//! launcher presente, ésta lo *adopta* y la app deja de pintarlo en su
//! ventana — el comportamiento "menú global" de macOS.
//! 3. **Launcher** ([`Launcher`] trait + [`LaunchError`]): la *instrucción
//! de ejecución* abstracta. El host implementa con `std::process`
//! ([`ProcessLauncher`]), wawa con instanciación WASM, shuma
//! despachando `action`. El motor de launcher llama al trait y no se
//! entera de en qué entorno corre.
//! 4. **Bus** ([`Bus`] + [`BusEvent`]): pub/sub in-process de foco /
//! cambio de menú / pedido de lanzamiento / comando. La versión
//! cross-proceso montará sobre el broker de brahman más adelante.
//!
//! Los **datos** ([`AppEntry`], [`Launch`], [`AppMenu`]…) y el **trait
//! [`Launcher`]** son `no_std + alloc`. El descubrimiento por filesystem,
//! el spawn de procesos y el [`Bus`] viven detrás del feature `std`.
#![cfg_attr(not(feature = "std"), no_std)]
#![forbid(unsafe_code)]
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
// =====================================================================
// Registro de apps
// =====================================================================
/// Cómo se enciende una app. Los tres mundos de gioser:
/// `Exec` (binario del host), `Action` (acción interna del chasis que la
/// hospeda — p.ej. `focus:shell`) y `Wasm` (módulo en el almacén de wawa,
/// direccionado por hash de bytecode).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Launch {
/// Spawnear un comando/binario del host.
Exec { program: String, args: Vec<String> },
/// Acción interna a despachar por el host (no spawnea proceso).
Action(String),
/// App WASM de wawa, por hash de bytecode (hex) en el almacén.
Wasm { bytecode_hex: String },
}
/// Una app registrada — la fila de la tabla que ven los launchers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppEntry {
pub id: String,
pub label: String,
/// Glyph/emoji o ruta de ícono. Sin imponer formato — el launcher
/// decide cómo pintarlo (texto en el dock MVP).
pub icon: Option<String>,
/// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante).
pub category: Option<String>,
pub launch: Launch,
/// Mimes/lentes que esta app sabe abrir (open-with). Vacío = no es
/// visor; el registro de visores de nahual-shell se alimenta de acá.
pub handles: Vec<String>,
}
impl AppEntry {
/// `true` si la app declara saber abrir `mime`.
pub fn handles_mime(&self, mime: &str) -> bool {
self.handles.iter().any(|m| m == mime)
}
}
#[cfg(feature = "std")]
impl AppEntry {
/// Enciende la app vía `std::process`. Sólo `Exec` spawnea; `Action`/
/// `Wasm` devuelven `Ok(None)` — los despacha el host (chasis/kernel).
pub fn spawn(&self) -> std::io::Result<Option<std::process::Child>> {
match &self.launch {
Launch::Exec { program, args } => std::process::Command::new(program)
.args(args)
.spawn()
.map(Some),
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
}
}
/// **Open-with out-of-process**: abre `target` con esta app. Para `Exec`,
/// spawnea el binario sustituyendo el placeholder `%f`/`%u` en los args
/// por `target`; si ningún arg lo trae, agrega `target` como último
/// argumento (semántica estilo freedesktop `Exec=app %f`). `Action`/`Wasm`
/// devuelven `Ok(None)`: el target lo despacha el host (chasis a una vista
/// in-process, o kernel de wawa a una app WASM), no un proceso del SO.
pub fn open(&self, target: &str) -> std::io::Result<Option<std::process::Child>> {
match &self.launch {
Launch::Exec { program, args } => std::process::Command::new(program)
.args(expand_target(args, target))
.spawn()
.map(Some),
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
}
}
}
/// Sustituye los placeholders `%f`/`%u` por `target` en `args`. Si ninguno
/// aparece, agrega `target` como argumento final — la convención de
/// freedesktop (`Exec=app %f`) que entiende cualquier "abrir con".
#[cfg(feature = "std")]
pub fn expand_target(args: &[String], target: &str) -> Vec<String> {
let mut sustituido = false;
let mut out: Vec<String> = args
.iter()
.map(|a| {
if a.contains("%f") || a.contains("%u") {
sustituido = true;
a.replace("%f", target).replace("%u", target)
} else {
a.clone()
}
})
.collect();
if !sustituido {
out.push(target.to_string());
}
out
}
// ----- forma en disco (TOML) -----
/// Espejo serde del archivo `<id>.toml`. La `[launch]` es una tabla con
/// campos opcionales en vez de un enum etiquetado — toml 0.8 trata los
/// enums internamente etiquetados de forma quisquillosa, así que
/// resolvemos a mano a [`Launch`]. Sólo se usa al parsear TOML (`std`).
#[cfg(feature = "std")]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppFile {
id: String,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
handles: Vec<String>,
launch: LaunchFile,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LaunchFile {
#[serde(default)]
exec: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
action: Option<String>,
#[serde(default)]
wasm: Option<String>,
}
#[cfg(feature = "std")]
impl LaunchFile {
fn resolve(self) -> Option<Launch> {
if let Some(program) = self.exec {
Some(Launch::Exec {
program,
args: self.args,
})
} else if let Some(action) = self.action {
Some(Launch::Action(action))
} else {
self.wasm.map(|bytecode_hex| Launch::Wasm { bytecode_hex })
}
}
}
#[cfg(feature = "std")]
impl AppFile {
fn into_entry(self) -> Option<AppEntry> {
Some(AppEntry {
id: self.id,
label: self.label,
icon: self.icon,
category: self.category,
handles: self.handles,
launch: self.launch.resolve()?,
})
}
}
/// Parsea una entrada de app desde texto TOML. Devuelve `None` si no
/// parsea o si la `[launch]` no nombra ningún modo (`exec`/`action`/`wasm`).
#[cfg(feature = "std")]
pub fn parse_entry(toml_src: &str) -> Option<AppEntry> {
toml::from_str::<AppFile>(toml_src)
.ok()
.and_then(AppFile::into_entry)
}
/// Directorio canónico del registro: `~/.config/gioser/apps/`.
#[cfg(feature = "std")]
pub fn apps_dir() -> Option<std::path::PathBuf> {
directories::BaseDirs::new().map(|b| b.config_dir().join("gioser").join("apps"))
}
/// La tabla de apps. Inmutable tras descubrir — recargar = volver a
/// `discover`. Barato: son pocos archivos y no es hot-path.
#[derive(Debug, Clone, Default)]
pub struct AppRegistry {
entries: Vec<AppEntry>,
}
impl AppRegistry {
pub fn new(mut entries: Vec<AppEntry>) -> Self {
// sort_unstable_by para no exigir alloc extra (vive en core).
entries.sort_unstable_by(|a, b| a.label.cmp(&b.label));
Self { entries }
}
pub fn all(&self) -> &[AppEntry] {
&self.entries
}
pub fn get(&self, id: &str) -> Option<&AppEntry> {
self.entries.iter().find(|e| e.id == id)
}
/// Apps que declaran abrir `mime` — para el open-with universal.
pub fn handlers_for(&self, mime: &str) -> Vec<&AppEntry> {
self.entries.iter().filter(|e| e.handles_mime(mime)).collect()
}
/// Apps de una categoría, en orden de label (para grilla/spotlight).
pub fn in_category(&self, category: &str) -> Vec<&AppEntry> {
self.entries
.iter()
.filter(|e| e.category.as_deref() == Some(category))
.collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(feature = "std")]
impl AppRegistry {
/// Descubre del dir canónico. Vacío si no hay config dir o el dir no
/// existe — la app sigue, sólo sin entradas.
pub fn discover() -> Self {
apps_dir().map(Self::from_dir).unwrap_or_default()
}
/// **Open-with universal**: elige el primer handler de `mime` (orden de
/// label) y le abre `target` out-of-process vía [`AppEntry::open`].
/// Devuelve el `AppEntry` elegido y su `Child` (o `None` en el child si
/// la app es `Action`/`Wasm`, que despacha el host). `Ok(None)` si ninguna
/// app registrada declara abrir ese mime — el caller cae a su visor
/// in-process por defecto (p.ej. el `viewer_registry` de nahual-shell).
pub fn open_with(
&self,
mime: &str,
target: &str,
) -> std::io::Result<Option<(&AppEntry, Option<std::process::Child>)>> {
match self.handlers_for(mime).into_iter().next() {
Some(entry) => Ok(Some((entry, entry.open(target)?))),
None => Ok(None),
}
}
/// Escanea `<dir>/*.toml`. Ignora en silencio los que no parsean
/// (con una nota a stderr), igual que el resto de los loaders del repo.
pub fn from_dir(dir: impl AsRef<std::path::Path>) -> Self {
let dir = dir.as_ref();
let mut entries = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
match std::fs::read_to_string(&p) {
Ok(src) => match parse_entry(&src) {
Some(entry) => entries.push(entry),
None => eprintln!("app-bus: {p:?} no declara una app válida"),
},
Err(err) => eprintln!("app-bus: no se pudo leer {p:?}: {err}"),
}
}
}
Self::new(entries)
}
}
/// Siembra manifests por defecto en [`apps_dir`] si todavía no hay
/// ninguno, para que [`AppRegistry::discover`] devuelva las apps del repo
/// en una máquina recién instalada. No pisa nada si ya existe algún
/// `*.toml`. Devuelve cuántos manifests escribió.
#[cfg(feature = "std")]
pub fn seed_default_apps() -> std::io::Result<usize> {
let Some(dir) = apps_dir() else {
return Ok(0);
};
// Si ya hay manifests, respetar la config del usuario y no tocar nada.
if let Ok(rd) = std::fs::read_dir(&dir) {
let ya_hay = rd.flatten().any(|e| {
e.path().extension().and_then(|s| s.to_str()) == Some("toml")
});
if ya_hay {
return Ok(0);
}
}
std::fs::create_dir_all(&dir)?;
// (id, label, icono, binario, cuadrante). Los binarios son los nombres
// de crate ejecutables del workspace; el cuadrante alimenta la grilla.
const DEFAULTS: &[(&str, &str, &str, &str, &str)] = &[
("cosmos", "Cosmos", "", "cosmos-app-llimphi", "yachay"),
("nada", "Nada", "", "nada", "ruway"),
("pluma", "Pluma", "", "pluma-editor-llimphi", "unanchay"),
("nahual", "Nahual", "", "nahual-shell-llimphi", "ruway"),
("dominium", "Dominium", "", "dominium-app-llimphi", "yachay"),
("tinkuy", "Tinkuy", "", "tinkuy-llimphi", "yachay"),
("takiy", "Takiy", "", "takiy-app-llimphi", "ruway"),
("media", "Media", "", "media-app", "ruway"),
("tullpu", "Tullpu", "", "tullpu-app-llimphi", "ruway"),
("supay", "Supay", "", "supay-app-llimphi", "ruway"),
("sandokan-monitor", "Monitor", "", "sandokan-monitor", "ukupacha"),
];
let mut escritos = 0;
for (id, label, icon, exec, cat) in DEFAULTS {
let toml = alloc::format!(
"id = \"{id}\"\nlabel = \"{label}\"\nicon = \"{icon}\"\ncategory = \"{cat}\"\n\n[launch]\nexec = \"{exec}\"\n"
);
std::fs::write(dir.join(alloc::format!("{id}.toml")), toml)?;
escritos += 1;
}
Ok(escritos)
}
// =====================================================================
// Menú global (Archivo / Editar / Ayuda …)
// =====================================================================
/// Un ítem de menú. `command` es el id que la app entiende: la barra lo
/// re-emite por el [`Bus`] como [`BusEvent::Command`] y la app focuseada
/// lo ejecuta. `shortcut` es sólo para pintar (la app sigue dueña del
/// binding real).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MenuItem {
pub label: String,
pub command: String,
#[serde(default)]
pub shortcut: Option<String>,
/// Glifo (unicode) opcional para el gutter de íconos del dropdown.
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "yes")]
pub enabled: bool,
/// Dibujar un separador *antes* de este ítem.
#[serde(default)]
pub separator_before: bool,
}
fn yes() -> bool {
true
}
impl MenuItem {
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
Self {
label: label.into(),
command: command.into(),
shortcut: None,
icon: None,
enabled: true,
separator_before: false,
}
}
pub fn shortcut(mut self, s: impl Into<String>) -> Self {
self.shortcut = Some(s.into());
self
}
/// Glifo del gutter izquierdo (unicode).
pub fn icon(mut self, glyph: impl Into<String>) -> Self {
self.icon = Some(glyph.into());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn separated(mut self) -> Self {
self.separator_before = true;
self
}
}
/// Un menú raíz (Archivo, Editar, Ayuda…) con sus ítems.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Menu {
pub label: String,
pub items: Vec<MenuItem>,
}
impl Menu {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
items: Vec::new(),
}
}
pub fn item(mut self, it: MenuItem) -> Self {
self.items.push(it);
self
}
}
/// El menú global completo de una app. La app lo declara; la barra de
/// launcher lo adopta (y entonces la app no lo pinta en su ventana).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppMenu {
pub menus: Vec<Menu>,
}
impl AppMenu {
pub fn new() -> Self {
Self::default()
}
pub fn menu(mut self, m: Menu) -> Self {
self.menus.push(m);
self
}
pub fn is_empty(&self) -> bool {
self.menus.is_empty()
}
/// Esqueleto estándar Archivo/Editar/Ayuda — punto de partida para que
/// toda app tenga un menú base coherente sin reinventarlo. Los
/// `command` siguen la convención `menu.<verbo>`; la app mapea los que
/// le sirven y deja `disabled` los que no.
pub fn standard() -> Self {
Self::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Nuevo", "file.new").shortcut("Ctrl+N"))
.item(MenuItem::new("Abrir…", "file.open").shortcut("Ctrl+O"))
.item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
.item(MenuItem::new("Cerrar", "file.close").shortcut("Ctrl+W").separated()),
)
.menu(
Menu::new("Editar")
.item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
.item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"))
.item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated())
.item(MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"))
.item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V")),
)
.menu(
Menu::new("Ayuda")
.item(MenuItem::new("Atajos", "help.shortcuts").shortcut("F1"))
.item(MenuItem::new("Acerca de", "help.about")),
)
}
}
// =====================================================================
// Launcher — la instrucción de ejecución abstracta
// =====================================================================
/// Por qué no se pudo lanzar una app.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchError {
/// Este `Launcher` no maneja el modo de la app (p.ej. un host que no
/// instancia WASM, o wawa que no spawnea procesos del host).
Unsupported,
/// El lanzamiento falló; mensaje libre.
Failed(String),
}
/// La *instrucción de ejecución* abstracta. El motor de launcher
/// (`launcher-core`/`launcher-llimphi`) llama a `launch` y no sabe en qué
/// entorno corre — host, shuma o wawa cada uno trae su impl.
pub trait Launcher {
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError>;
}
/// Launcher del host: spawnea binarios vía `std::process`. No maneja
/// `Action`/`Wasm` (devuelve `Unsupported` — esos los resuelve el chasis).
#[cfg(feature = "std")]
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessLauncher;
#[cfg(feature = "std")]
impl Launcher for ProcessLauncher {
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError> {
match app.spawn() {
Ok(Some(_child)) => Ok(()),
Ok(None) => Err(LaunchError::Unsupported),
Err(e) => Err(LaunchError::Failed(alloc::string::ToString::to_string(&e))),
}
}
}
// =====================================================================
// Bus de eventos (pub/sub in-process) — sólo `std`
// =====================================================================
/// Lo que viaja por el bus. El flujo del menú global: una app toma foco
/// → `AppFocused` + `MenuChanged` → la barra adopta el menú → el usuario
/// clickea un ítem → la barra emite `Command` → la app focuseada lo
/// ejecuta. El dock/spotlight emiten `LaunchRequested` y el shell lo
/// resuelve contra el [`AppRegistry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BusEvent {
/// Una app tomó foco — la barra debería adoptar su menú.
AppFocused { app_id: String },
/// El menú de una app cambió (ítems habilitados/labels dinámicos).
MenuChanged { app_id: String, menu: AppMenu },
/// Dock/spotlight pidieron lanzar una app por id.
LaunchRequested { app_id: String },
/// La barra global disparó un comando del menú hacia la app focuseada.
Command { app_id: String, command: String },
}
/// Bus pub/sub mínimo y `Send + Sync`: fan-out a todos los suscriptores.
/// Un suscriptor caído (receiver dropeado) se poda en el próximo publish.
/// Clonar el `Bus` comparte el mismo conjunto de suscriptores.
#[cfg(feature = "std")]
#[derive(Clone, Default)]
pub struct Bus {
subs: std::sync::Arc<std::sync::Mutex<Vec<std::sync::mpsc::Sender<BusEvent>>>>,
}
#[cfg(feature = "std")]
impl Bus {
pub fn new() -> Self {
Self::default()
}
/// Crea un canal y devuelve su extremo de recepción. El emisor queda
/// registrado para recibir cada `publish` futuro.
pub fn subscribe(&self) -> std::sync::mpsc::Receiver<BusEvent> {
let (tx, rx) = std::sync::mpsc::channel();
self.subs.lock().unwrap().push(tx);
rx
}
/// Emite a todos los suscriptores vivos. Devuelve cuántos lo recibieron.
pub fn publish(&self, ev: BusEvent) -> usize {
let mut subs = self.subs.lock().unwrap();
subs.retain(|tx| tx.send(ev.clone()).is_ok());
subs.len()
}
}
// =====================================================================
// Tests (corren con default features = std)
// =====================================================================
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
#[test]
fn parse_exec_entry() {
let src = r#"
id = "cosmos"
label = "Cosmos"
icon = "✶"
category = "yachay"
handles = ["application/x-cosmos-chart"]
[launch]
exec = "cosmos-app-llimphi"
args = ["--release"]
"#;
let e = parse_entry(src).expect("parsea");
assert_eq!(e.id, "cosmos");
assert_eq!(e.icon.as_deref(), Some(""));
assert!(e.handles_mime("application/x-cosmos-chart"));
assert_eq!(
e.launch,
Launch::Exec {
program: "cosmos-app-llimphi".into(),
args: vec!["--release".into()],
}
);
}
#[test]
fn parse_action_and_wasm() {
let a = parse_entry("id='s'\nlabel='Shell'\n[launch]\naction='focus:shell'").unwrap();
assert_eq!(a.launch, Launch::Action("focus:shell".into()));
let w = parse_entry("id='h'\nlabel='Hola'\n[launch]\nwasm='deadbeef'").unwrap();
assert_eq!(w.launch, Launch::Wasm { bytecode_hex: "deadbeef".into() });
}
#[test]
fn launch_sin_modo_es_none() {
assert!(parse_entry("id='x'\nlabel='X'\n[launch]").is_none());
}
#[test]
fn registry_from_dir_y_consultas() {
let dir =
std::env::temp_dir().join(format!("app-bus-test-{}-{}", std::process::id(), "reg"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("cosmos.toml"),
"id='cosmos'\nlabel='Cosmos'\ncategory='yachay'\nhandles=['x/chart']\n[launch]\nexec='cosmos-app-llimphi'",
)
.unwrap();
std::fs::write(
dir.join("nada.toml"),
"id='nada'\nlabel='Nada'\ncategory='ruway'\n[launch]\nexec='nada'",
)
.unwrap();
std::fs::write(dir.join("roto.toml"), "no es toml válido = =").unwrap();
let reg = AppRegistry::from_dir(&dir);
assert_eq!(reg.len(), 2);
assert_eq!(reg.all()[0].id, "cosmos");
assert_eq!(reg.get("nada").unwrap().label, "Nada");
assert_eq!(reg.handlers_for("x/chart").len(), 1);
assert_eq!(reg.in_category("yachay").len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers() {
// Los manifiestos de `assets/apps/` (las apps reales de la suite que se
// copian a ~/.config/gioser/apps/) deben parsear y declarar sus mimes.
// Canario del formato: si cambia el esquema, esto avisa.
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/apps");
let reg = AppRegistry::from_dir(&dir);
assert_eq!(reg.len(), 2, "media + nada");
// media abre video/audio; nada, texto/código.
assert_eq!(reg.handlers_for("video/mp4")[0].id, "media");
assert_eq!(reg.handlers_for("text/x-rust")[0].id, "nada");
// El exec lleva el placeholder freedesktop.
let media = reg.get("media").unwrap();
assert_eq!(
media.launch,
Launch::Exec {
program: "media-app".into(),
args: vec!["%f".into()],
}
);
}
#[test]
fn menu_estandar_y_builder() {
let m = AppMenu::standard();
assert_eq!(m.menus.len(), 3);
assert_eq!(m.menus[0].label, "Archivo");
let custom = AppMenu::new().menu(
Menu::new("Carta").item(MenuItem::new("Duplicar", "carta.dup").shortcut("Ctrl+D")),
);
assert_eq!(custom.menus[0].items[0].command, "carta.dup");
}
#[test]
fn menu_roundtrip_serde() {
let m = AppMenu::standard();
let json = serde_json::to_string(&m).unwrap();
let back: AppMenu = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn process_launcher_unsupported_para_action() {
// Action no es del host → Unsupported (no intenta spawnear).
let app = AppEntry {
id: "s".into(),
label: "Shell".into(),
icon: None,
category: None,
launch: Launch::Action("focus:shell".into()),
handles: Vec::new(),
};
assert_eq!(ProcessLauncher.launch(&app), Err(LaunchError::Unsupported));
}
#[test]
fn bus_fanout_y_poda() {
let bus = Bus::new();
let a = bus.subscribe();
let b = bus.subscribe();
let n = bus.publish(BusEvent::LaunchRequested {
app_id: "cosmos".into(),
});
assert_eq!(n, 2);
assert!(matches!(a.recv().unwrap(), BusEvent::LaunchRequested { .. }));
assert!(matches!(b.recv().unwrap(), BusEvent::LaunchRequested { .. }));
drop(a);
let n = bus.publish(BusEvent::AppFocused {
app_id: "nada".into(),
});
assert_eq!(n, 1);
}
// ===== open-with out-of-process =====
#[test]
fn expand_target_sustituye_placeholder() {
let args = vec!["--open".to_string(), "%f".to_string()];
assert_eq!(
expand_target(&args, "/tmp/x.png"),
vec!["--open".to_string(), "/tmp/x.png".to_string()]
);
// `%u` también; y substitución embebida en un arg compuesto.
let args = vec!["url=%u".to_string()];
assert_eq!(expand_target(&args, "http://a"), vec!["url=http://a".to_string()]);
}
#[test]
fn expand_target_agrega_si_no_hay_placeholder() {
let args = vec!["--flag".to_string()];
assert_eq!(
expand_target(&args, "/tmp/x.png"),
vec!["--flag".to_string(), "/tmp/x.png".to_string()]
);
}
#[test]
fn open_with_sin_handler_devuelve_none() {
let reg = AppRegistry::new(vec![]);
assert!(reg.open_with("image/png", "/tmp/x.png").unwrap().is_none());
}
#[test]
fn open_with_spawnea_handler_y_le_pasa_el_target() {
use std::io::Read;
// Archivo donde el "handler" escribirá el target que recibió.
let out =
std::env::temp_dir().join(format!("app-bus-openwith-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&out);
// Handler = sh que escribe $1 (el target expandido en %f) al archivo.
let entry = AppEntry {
id: "writer".into(),
label: "Writer".into(),
icon: None,
category: None,
launch: Launch::Exec {
program: "sh".into(),
args: vec![
"-c".into(),
format!("printf '%s' \"$1\" > {}", out.display()),
"_".into(),
"%f".into(),
],
},
handles: vec!["image/png".into()],
};
let reg = AppRegistry::new(vec![entry]);
let (chosen, child) = reg
.open_with("image/png", "TARGET-123")
.unwrap()
.expect("debe haber handler para image/png");
assert_eq!(chosen.id, "writer");
child.expect("Exec debe spawnear un Child").wait().unwrap();
let mut s = String::new();
std::fs::File::open(&out)
.unwrap()
.read_to_string(&mut s)
.unwrap();
assert_eq!(s, "TARGET-123", "el handler recibió el target en %f");
let _ = std::fs::remove_file(&out);
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "auth-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "auth-core — autenticación del escritorio: contrato Authenticator agnóstico + backend PAM + mock. Lo consume el greeter de carmen (mirada)."
[dependencies]
nix = { workspace = true }
thiserror = { workspace = true }
pam = { workspace = true }
[dev-dependencies]
rpassword = { workspace = true }
+52
View File
@@ -0,0 +1,52 @@
# auth-core
Autenticación del escritorio. Contrato `Authenticator` agnóstico del
backend, con dos implementaciones.
## Para qué
El greeter de carmen (mirada) necesita verificar la contraseña del
usuario y, en éxito, saber su `uid/gid/home/shell` para arrancar la
sesión. Eso es exactamente lo que entrega `Authenticator::authenticate`:
```rust
use brahman_auth::{Authenticator, PamAuthenticator};
let auth = PamAuthenticator::carmen();
match auth.authenticate("sergio", &password) {
Ok(info) => arrancar_sesion(info), // info: UserInfo
Err(e) => mostrar_error_en_greeter(e),
}
```
## Backends
- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`),
el mismo subsistema de `login` y `sudo`. Hereda lo que el
administrador configure ahí (2FA, FIDO2, `pam_faillock`…) sin que el
crate lo sepa.
- **`MockAuthenticator`** — credenciales fijas en memoria. Para tests y
para iterar el greeter en cajas sin PAM configurado.
`AuthError` es deliberadamente grueso: el greeter sólo distingue
"reintentá" (`BadCredentials`) de "cuenta vetada" (`AccountUnavailable`),
y nunca puede saber si un usuario existe.
## Servicio PAM
`data/carmen` es el archivo de servicio. Instalarlo:
```sh
install -Dm644 data/carmen /etc/pam.d/carmen
```
Ajustar el `include` a la pila de login de la distribución (ver los
comentarios del archivo).
## Probar contra PAM en una máquina real
```sh
cargo run -p auth-core --example auth-probe -- "$USER" login
```
Pide la contraseña sin eco e informa el `UserInfo` resuelto.
+15
View File
@@ -0,0 +1,15 @@
#%PAM-1.0
#
# Servicio PAM del greeter de carmen (mirada). Instalar como
# /etc/pam.d/carmen.
#
# El `include` apunta a la pila de login de la distribución; ajustar
# según corresponda:
# Arch system-login
# Debian/Ubuntu common-auth / common-account / ... (una por línea)
# Fedora/RHEL system-auth + postlogin
#
auth include system-login
account include system-login
password include system-login
session include system-login
@@ -0,0 +1,41 @@
//! Prueba interactiva de `brahman-auth` contra PAM. Sirve para verificar
//! la configuración de `/etc/pam.d/<servicio>` en una máquina real.
//!
//! `cargo run -p brahman-auth --example auth-probe -- [usuario] [servicio]`
//!
//! Pide la contraseña sin eco. El servicio por defecto es `carmen`; si
//! `/etc/pam.d/carmen` aún no está instalado, probar con `login`.
use auth_core::{Authenticator, PamAuthenticator};
fn main() {
let mut args = std::env::args().skip(1);
let user = args
.next()
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "root".into());
let service = args.next().unwrap_or_else(|| "carmen".into());
let password = match rpassword::prompt_password(format!("Contraseña de {user}: ")) {
Ok(p) => p,
Err(e) => {
eprintln!("no se pudo leer la contraseña: {e}");
std::process::exit(2);
}
};
let auth = PamAuthenticator::new(&service);
println!("autenticando «{user}» contra el servicio PAM «{service}»…");
match auth.authenticate(&user, &password) {
Ok(info) => {
println!("✓ autenticado");
println!(" uid={} gid={}", info.uid, info.gid);
println!(" home={}", info.home.display());
println!(" shell={}", info.shell.display());
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `brahman-auth` — autenticación del escritorio.
//!
//! Contrato [`Authenticator`] agnóstico del backend, con dos
//! implementaciones:
//!
//! - [`PamAuthenticator`] — el camino real: verifica contra PAM
//! (`/etc/pam.d/<servicio>`), el mismo subsistema que usan `login`,
//! `sudo` y los gestores de login clásicos. Hereda lo que el
//! administrador configure ahí (2FA, llaves FIDO2, `pam_faillock`…)
//! sin que `brahman-auth` tenga que saberlo.
//! - [`MockAuthenticator`] — credenciales fijas en memoria, para tests
//! y para iterar el greeter en cajas sin PAM configurado.
//!
//! Lo consume el greeter de carmen (mirada): el usuario teclea su
//! contraseña, el greeter llama a [`Authenticator::authenticate`], y en
//! éxito recibe un [`UserInfo`] con uid/gid/home/shell — lo que el
//! compositor necesita para arrancar la sesión.
mod pam_backend;
mod ticket;
mod user;
pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE};
pub use ticket::{SessionTicket, TICKET_TAG};
pub use user::{resolve_user, UserInfo};
use std::collections::HashMap;
/// Por qué falló una autenticación. Variantes deliberadamente gruesas:
/// el greeter sólo necesita saber si conviene reintentar (problema de
/// credenciales) o si la cuenta está vetada — y nunca debe poder
/// distinguir "usuario inexistente" de "contraseña errada".
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
/// Usuario o contraseña incorrectos. El greeter deja reintentar sin
/// revelar cuál de los dos falló.
#[error("usuario o contraseña incorrectos")]
BadCredentials,
/// Las credenciales son válidas pero la cuenta está deshabilitada,
/// expirada o requiere una acción (cambio de contraseña).
#[error("la cuenta no está disponible: {0}")]
AccountUnavailable(String),
/// Fallo del subsistema PAM no atribuible a las credenciales
/// (servicio mal configurado, módulo roto, etc.).
#[error("fallo de PAM: {0}")]
Pam(String),
/// No se pudo resolver la identidad del usuario en el sistema tras
/// una autenticación válida (caso raro: `/etc/passwd` inconsistente).
#[error("no se pudo resolver el usuario «{0}» en el sistema")]
UnresolvedUser(String),
}
/// Verifica credenciales y, en éxito, entrega la identidad del sistema.
///
/// `&self`: cada llamada es un intento de login independiente. Las
/// implementaciones crean su propio estado por intento — PAM exige un
/// handle nuevo por transacción, reusarlo entre intentos es un bug.
pub trait Authenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError>;
}
/// Autenticador de credenciales fijas en memoria. No toca PAM: sirve
/// para tests y para iterar el greeter en cajas headless sin
/// `/etc/pam.d` configurado.
#[derive(Debug, Default, Clone)]
pub struct MockAuthenticator {
creds: HashMap<String, String>,
}
impl MockAuthenticator {
/// Crea un autenticador sin usuarios: todo intento falla con
/// [`AuthError::BadCredentials`] hasta registrar alguno.
pub fn new() -> Self {
Self::default()
}
/// Registra un par usuario/secreto aceptado. Encadenable.
pub fn with_user(mut self, username: &str, secret: &str) -> Self {
self.creds.insert(username.to_string(), secret.to_string());
self
}
}
impl Authenticator for MockAuthenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
// Mismo error para usuario inexistente y para contraseña errada:
// no filtra la existencia de cuentas.
match self.creds.get(username) {
Some(expected) if expected == secret => {
// Si el usuario existe en el SO, info real; sino,
// sintética (suficiente para tests y dev headless).
Ok(resolve_user(username).unwrap_or_else(|_| UserInfo::synthetic(username)))
}
_ => Err(AuthError::BadCredentials),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_accepts_registered_user() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
let info = auth.authenticate("sergio", "clave").expect("debe pasar");
assert_eq!(info.name, "sergio");
}
#[test]
fn mock_rejects_wrong_password() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
assert_eq!(
auth.authenticate("sergio", "mala"),
Err(AuthError::BadCredentials)
);
}
#[test]
fn mock_unknown_user_indistinguishable_from_wrong_password() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
assert_eq!(
auth.authenticate("nadie", "x"),
Err(AuthError::BadCredentials)
);
}
#[test]
fn empty_mock_rejects_everything() {
assert!(MockAuthenticator::new().authenticate("root", "").is_err());
}
#[test]
fn auth_error_is_displayable() {
assert!(!AuthError::BadCredentials.to_string().is_empty());
assert!(AuthError::Pam("x".into()).to_string().contains("PAM"));
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Backend PAM del contrato [`Authenticator`](crate::Authenticator).
//!
//! El módulo se llama `pam_backend` (no `pam`) para no chocar con el
//! crate externo `pam`, del que depende.
use pam::{Client, PamError, PamReturnCode};
use crate::{resolve_user, AuthError, Authenticator, UserInfo};
/// Servicio PAM por defecto del escritorio carmen. Resuelve a
/// `/etc/pam.d/carmen` — ver el archivo `data/carmen` de este crate.
pub const DEFAULT_SERVICE: &str = "carmen";
/// Autentica contra PAM: el mismo subsistema de `login`/`sudo`. Honra
/// `/etc/pam.d/<service>` — módulos, 2FA, llaves FIDO2, `pam_faillock`,
/// lo que el administrador configure ahí, sin que `brahman-auth` lo sepa.
#[derive(Debug, Clone)]
pub struct PamAuthenticator {
service: String,
}
impl PamAuthenticator {
/// Autenticador para un servicio PAM concreto (`/etc/pam.d/<service>`).
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
/// Autenticador para el servicio por defecto del escritorio,
/// [`DEFAULT_SERVICE`].
pub fn carmen() -> Self {
Self::new(DEFAULT_SERVICE)
}
/// Nombre del servicio PAM que usa este autenticador.
pub fn service(&self) -> &str {
&self.service
}
}
impl Default for PamAuthenticator {
fn default() -> Self {
Self::carmen()
}
}
impl Authenticator for PamAuthenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
// Un handle PAM nuevo por intento: PAM es stateful por
// transacción y reusar el handle entre intentos es un bug. El
// `Client` cierra la transacción (`pam_end`) en su `Drop`.
let mut client = Client::with_password(&self.service)
.map_err(|e| AuthError::Pam(format!("pam_start({}): {e}", self.service)))?;
client.conversation_mut().set_credentials(username, secret);
// `authenticate()` del crate hace pam_authenticate + pam_acct_mgmt:
// cubre credenciales Y estado de la cuenta en un solo paso.
client.authenticate().map_err(map_pam_error)?;
// Credenciales válidas: resolvemos la identidad del sistema.
resolve_user(username)
}
}
/// Traduce un error de PAM a la taxonomía gruesa de [`AuthError`].
fn map_pam_error(err: PamError) -> AuthError {
match err.0 {
// Credenciales: el greeter debe dejar reintentar.
PamReturnCode::Auth_Err
| PamReturnCode::User_Unknown
| PamReturnCode::Cred_Insufficient
| PamReturnCode::MaxTries => AuthError::BadCredentials,
// Cuenta válida pero vetada o que requiere una acción.
PamReturnCode::Acct_Expired => AuthError::AccountUnavailable("la cuenta expiró".into()),
PamReturnCode::Cred_Expired => {
AuthError::AccountUnavailable("las credenciales expiraron".into())
}
PamReturnCode::AuthTok_Expired => {
AuthError::AccountUnavailable("la contraseña expiró".into())
}
PamReturnCode::New_Authtok_Reqd => {
AuthError::AccountUnavailable("requiere cambiar la contraseña".into())
}
PamReturnCode::Perm_Denied => {
AuthError::AccountUnavailable("acceso denegado por política".into())
}
// Todo lo demás: fallo de infraestructura PAM.
other => AuthError::Pam(format!("{other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn carmen_uses_default_service() {
assert_eq!(PamAuthenticator::carmen().service(), DEFAULT_SERVICE);
assert_eq!(PamAuthenticator::default().service(), "carmen");
}
#[test]
fn custom_service_name() {
assert_eq!(PamAuthenticator::new("login").service(), "login");
}
#[test]
fn unknown_service_fails_gracefully() {
// Sin `/etc/pam.d/<servicio>` PAM cae a `other` (deny). Debe
// devolver un `AuthError`, nunca paniquear.
let auth = PamAuthenticator::new("brahman-auth-servicio-inexistente-xyz");
assert!(
auth.authenticate("root", "contraseña-cualquiera").is_err(),
"un servicio inexistente debe fallar limpio"
);
}
}
+175
View File
@@ -0,0 +1,175 @@
//! El tiquet de sesión: lo que el greeter le entrega al compositor tras
//! una autenticación exitosa.
//!
//! El greeter de carmen corre como proceso hijo del compositor. Cuando
//! el login tiene éxito, imprime **una línea** de tiquet a su stdout; el
//! compositor escanea las líneas del hijo buscando el prefijo
//! [`TICKET_TAG`] y, al encontrarlo, hace el traspaso a modo sesión.
use std::path::PathBuf;
use crate::UserInfo;
/// Etiqueta + versión de la línea de tiquet. El compositor sólo trata
/// como tiquet las líneas que empiezan con esto — el resto del stdout
/// del greeter (logs, ruido) se ignora.
pub const TICKET_TAG: &str = "MIRADA-SESSION-TICKET-v1";
/// Resultado de un login: la identidad autenticada más, opcionalmente,
/// el comando de sesión elegido. El greeter lo produce; el compositor lo
/// consume para arrancar la sesión (setuid al usuario + spawn).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionTicket {
/// Identidad del usuario autenticado.
pub user: UserInfo,
/// Comando de sesión a ejecutar como el usuario. Vacío = que el
/// compositor decida (su autostart por defecto).
pub session: String,
/// `true` si la sesión es un compositor **ajeno** (sway, Plasma…): el
/// servidor actual debe soltar el DRM y hacer `exec`, no correrla como
/// cliente. `false` para sesiones nativas de mirada (pata, autostart),
/// que sí corren como clientes del mismo compositor.
pub foreign: bool,
}
impl SessionTicket {
/// Crea un tiquet sin comando de sesión explícito.
pub fn new(user: UserInfo) -> Self {
Self {
user,
session: String::new(),
foreign: false,
}
}
/// Fija el comando de sesión. Encadenable.
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session = session.into();
self
}
/// Marca la sesión como compositor ajeno (handoff por `exec`).
/// Encadenable.
pub fn foreign(mut self, foreign: bool) -> Self {
self.foreign = foreign;
self
}
/// Serializa el tiquet a una línea única, apta para stdout. Campos
/// separados por tabulador: ni los nombres de usuario, ni los paths,
/// ni los comandos de sesión suelen contener tabuladores.
pub fn to_line(&self) -> String {
format!(
"{TICKET_TAG}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
self.user.name,
self.user.uid,
self.user.gid,
self.user.home.display(),
self.user.shell.display(),
self.session,
if self.foreign { "1" } else { "0" },
)
}
/// Parsea una línea producida por [`to_line`]. `None` si la línea no
/// es un tiquet (otra salida del greeter) o está malformada.
pub fn from_line(line: &str) -> Option<SessionTicket> {
let mut f = line.trim_end_matches(['\r', '\n']).split('\t');
if f.next()? != TICKET_TAG {
return None;
}
let name = f.next()?.to_string();
let uid = f.next()?.parse().ok()?;
let gid = f.next()?.parse().ok()?;
let home = PathBuf::from(f.next()?);
let shell = PathBuf::from(f.next()?);
// El comando de sesión puede venir vacío.
let session = f.next().unwrap_or("").to_string();
// El flag `foreign` es opcional (tiquets viejos no lo traen).
let foreign = matches!(f.next(), Some("1"));
Some(SessionTicket {
user: UserInfo {
name,
uid,
gid,
home,
shell,
},
session,
foreign,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> UserInfo {
UserInfo {
name: "sergio".into(),
uid: 1000,
gid: 1000,
home: PathBuf::from("/home/sergio"),
shell: PathBuf::from("/usr/bin/bash"),
}
}
#[test]
fn round_trip_without_session() {
let t = SessionTicket::new(sample());
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert!(back.session.is_empty());
}
#[test]
fn round_trip_with_session() {
let t = SessionTicket::new(sample()).with_session("shuma-shell --launcher");
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert_eq!(back.session, "shuma-shell --launcher");
}
#[test]
fn round_trip_with_foreign() {
let t = SessionTicket::new(sample())
.with_session("sway")
.foreign(true);
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert!(back.foreign);
}
#[test]
fn foreign_defaults_false_sin_campo() {
// Una línea estilo v1 (sin el campo foreign) parsea con foreign=false.
let line = format!("{TICKET_TAG}\tsergio\t1000\t1000\t/home/sergio\t/usr/bin/bash\tsway");
let back = SessionTicket::from_line(&line).expect("parsea");
assert!(!back.foreign);
assert_eq!(back.session, "sway");
}
#[test]
fn from_line_ignores_non_ticket() {
assert!(SessionTicket::from_line("[INFO] arrancando greeter").is_none());
assert!(SessionTicket::from_line("").is_none());
}
#[test]
fn from_line_rejects_malformed() {
// Prefijo correcto pero faltan campos.
assert!(SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio")).is_none());
// uid no numérico.
assert!(
SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio\tXX\t1000\t/h\t/sh\t"))
.is_none()
);
}
#[test]
fn tolerates_trailing_newline() {
let line = format!("{}\n", SessionTicket::new(sample()).to_line());
assert!(SessionTicket::from_line(&line).is_some());
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Resolución de la identidad de un usuario del sistema.
use std::path::PathBuf;
use crate::AuthError;
/// Identidad de un usuario en el sistema: lo que el compositor necesita
/// para arrancar una sesión — fijar uid/gid, `cd` al home, ejecutar el
/// shell o la sesión de escritorio.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserInfo {
/// Nombre de login.
pub name: String,
/// User ID.
pub uid: u32,
/// Group ID primario.
pub gid: u32,
/// Directorio personal.
pub home: PathBuf,
/// Shell de login.
pub shell: PathBuf,
}
impl UserInfo {
/// Identidad sintética para tests y para cajas donde el usuario no
/// está en `/etc/passwd`. **No** representa a un usuario real del SO
/// — no usar para fijar privilegios de un proceso real.
pub fn synthetic(name: &str) -> Self {
Self {
name: name.to_string(),
uid: 1000,
gid: 1000,
home: PathBuf::from(format!("/home/{name}")),
shell: PathBuf::from("/bin/sh"),
}
}
}
/// Resuelve un usuario por nombre vía `getpwnam`. `Err` si no existe o
/// si la consulta a `/etc/passwd` (o NSS) falla.
pub fn resolve_user(name: &str) -> Result<UserInfo, AuthError> {
match nix::unistd::User::from_name(name) {
Ok(Some(u)) => Ok(UserInfo {
name: u.name,
uid: u.uid.as_raw(),
gid: u.gid.as_raw(),
home: u.dir,
shell: u.shell,
}),
Ok(None) => Err(AuthError::UnresolvedUser(name.to_string())),
Err(e) => Err(AuthError::Pam(format!("getpwnam({name}): {e}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_root() {
// root (uid 0) existe en todo sistema Unix.
let info = resolve_user("root").expect("root debe existir");
assert_eq!(info.uid, 0);
assert_eq!(info.name, "root");
}
#[test]
fn unknown_user_errs() {
let r = resolve_user("usuario-que-no-existe-xyzzy");
assert!(matches!(r, Err(AuthError::UnresolvedUser(_))));
}
#[test]
fn synthetic_has_home_under_slash_home() {
let info = UserInfo::synthetic("prueba");
assert_eq!(info.home, PathBuf::from("/home/prueba"));
assert_eq!(info.uid, 1000);
}
}
+41
View File
@@ -0,0 +1,41 @@
# =============================================================================
# renaser :: format — el format del grafo de objetos en disco
# -----------------------------------------------------------------------------
# Nucleo `#![no_std]` COMPARTIDO: lo enlaza el kernel bare-metal (target
# `x86_64-unknown-none`) y, por ser no_std, tambien lo compila sin friccion el
# anfitrion `boot`. Es la unica verdad del format del grafo —tipos,
# (de)serializacion postcard, hash BLAKE3, trazado de registros—, de modo que
# kernel y constructor de imagen hablen exactamente el mismo idioma de disco.
#
# Queda EXCLUIDO del espacio de trabajo (ver el Cargo.toml raiz), como el
# kernel: lo consume un paquete bare-metal, asi que fija sus versiones de
# forma explicita, sin herencia del workspace.
# =============================================================================
[package]
name = "format"
version = "0.1.0"
edition = "2021"
license = "MPL-2.0"
authors = ["JL Soltech <gerencia@jlsoltech.com>"]
description = "renaser :: format del grafo de objetos en disco — compartido kernel ↔ boot"
[lib]
bench = false
doctest = false
[dependencies]
# `serde` da el rasgo de (de)serializacion; `postcard` lo materializa en un
# format binario compacto — el que viaja al disco. Ambos `no_std`, sobre `alloc`.
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
# `serde-big-array` cubre el hueco de serde con arrays mayores a 32 bytes: las
# firmas Ed25519 (`Firma = [u8; 64]`) lo requieren. Compatible `no_std`.
serde-big-array = { version = "0.5", default-features = false }
# `blake3`: la funcion hash que da identidad a cada objeto. Se fuerza la
# implementacion ESCALAR pura (`pure` + los cuatro `no_*`): el target del kernel
# corre sin SSE, y un camino SIMD por deteccion en tiempo de ejecucion
# ejecutaria instrucciones que la CPU, sin `CR4.OSFXSR`, rechazaria con un #UD.
blake3 = { version = "1", default-features = false, features = [
"pure", "no_sse2", "no_sse41", "no_avx2", "no_avx512",
] }
+36
View File
@@ -0,0 +1,36 @@
# format — el formato nativo de gioser
Tipos canónicos del **DAG direccionado por contenido** (BLAKE3 + postcard),
compartidos entre host y kernel `wawa`. `#![no_std]` — cruza la frontera al
kernel bare-metal por `path`. Es el formato en el que TODO el suite trabaja en
nativo (los formatos ajenos entran por `shared/foreign-*` y se convierten a
esto).
## Módulos
- `tipos` — objetos, hashes, identidades de contenido.
- `cable` — referencias entre objetos (aristas del DAG).
- `firma` — firmas Ed25519 y verificación.
- `pruebas` — pruebas de revocación de capacidades (WAWA.md §14.1.3).
- `grafo` — construcción/recorrido del DAG.
- `constantes` — parámetros del formato (tamaños, versiones).
## Estado (2026-05-31)
### Hecho
- Tipos canónicos del DAG (objetos, cables, hashes) en `no_std`, validados en
`wasm32-unknown-unknown` por `scripts/check-shared-cores.sh`.
- Firma/verificación Ed25519 (`firma`) y pruebas de revocación (`pruebas`),
canónicos compartidos kernel↔host para el enforcement §14.1.3.
- `lib.rs` (2327 LOC) **dividido en módulos temáticos** (cable/firma/grafo/…).
- Suite amplia (~52 tests).
### Pendiente
- Versionado/migración del formato en disco (campo de versión existe; políticas
de upgrade aún por definir).
- Más cobertura de los caminos de revocación end-to-end.
## Lugar en el repo
`shared/format` — núcleo `no_std` compartido. Lo consumen apps, `agora` y el
kernel `wawa`.
+290
View File
@@ -0,0 +1,290 @@
use super::*;
// =============================================================================
// El hash y el trazado de un registro en el log
// =============================================================================
/// La identidad de un objeto: el hash BLAKE3 de su forma serializada. Kernel y
/// `boot` la calculan por aqui — una sola definicion del hash, jamas dos.
pub fn hash(bytes: &[u8]) -> Hash {
*blake3::hash(bytes).as_bytes()
}
/// Numero de sectores que ocupa un registro cuyo payload mide `longitud`
/// bytes. Cada registro es `[longitud: u32 LE][payload postcard][relleno 0]`.
pub fn sectores_registro(longitud: usize) -> u64 {
(4 + longitud).div_ceil(TAM_SECTOR) as u64
}
/// Compone el registro en disco de un payload: `[longitud u32 LE][payload]
/// [relleno a cero]`, alineado a un numero entero de sectores. Es el trazado
/// exacto que el kernel lee al reconstruir su indice — lo escriben tanto
/// `kernel::almacen` (al anexar un objeto) como `boot` (al sembrar la imagen).
pub fn componer_registro(payload: &[u8]) -> Vec<u8> {
let n = sectores_registro(payload.len()) as usize;
let mut registro = vec![0u8; n * TAM_SECTOR];
registro[0..4].copy_from_slice(&(payload.len() as u32).to_le_bytes());
registro[4..4 + payload.len()].copy_from_slice(payload);
registro
}
/// Lee la cabecera de longitud de un registro (sus 4 primeros bytes). Devuelve
/// `None` si la longitud es cero —fin del log— o supera [`MAX_OBJETO`]
/// —corrupcion—. Gemela de [`componer_registro`].
pub fn longitud_registro(cabecera: &[u8]) -> Option<usize> {
if cabecera.len() < 4 {
return None;
}
let longitud =
u32::from_le_bytes([cabecera[0], cabecera[1], cabecera[2], cabecera[3]]) as usize;
if longitud == 0 || longitud > MAX_OBJETO {
None
} else {
Some(longitud)
}
}
// =============================================================================
// Fase 60 — Asistente Akasha: tipos de mensaje del canal del asistente
// -----------------------------------------------------------------------------
// La app `asistente.wasm` (kernel-side) y el `asistente-puente` (host-side)
// conversan por un canal Akasha bien conocido. Estos tipos definen el
// protocolo. Diseñado para serializarse con `postcard` (el mismo encoder
// que usa todo el resto del kernel) y vivir en `#![no_std] + alloc` para
// cruzar la frontera kernel-wasm sin friction.
//
// ESTADO (Fase 60 v1): tipos definidos, sin código que los consuma todavía.
// Ver `docs/ASISTENTE_WAWA.md` §2.2 para el contexto del diseño.
// =============================================================================
/// Canal Akasha bien conocido para el asistente. ASCII `"AS"` = 0x4153. El
/// kernel filtra paquetes con este canal hacia los suscriptores del oficio
/// asistente; el puente Linux abre un socket raw que suscribe al mismo
/// número para recibir consultas y enviar propuestas.
///
/// NOTA: 0x4153 está dentro del rango histórico de "longitud" de Ethernet
/// (< 0x0600), así que NO sirve como EtherType. Para los frames del
/// asistente sobre el cable se usa [`ETHERTYPE_ASISTENTE`]; este valor
/// queda como discriminante interno (postcard tag, identificador del
/// oficio en logs y trazas).
pub const CANAL_ASISTENTE: u16 = 0x4153;
/// EtherType de los frames del asistente sobre el cable. Vecino del
/// 0x88B5 que ya usa Akasha — los dos viven en el rango "experimental"
/// que la IEEE deja libre. El demuxer Akasha del kernel (`akasha.rs`)
/// trata frames con EtherType ajeno como "para el usuario": los encola
/// tal cual sin procesar. La app `asistente.wasm` los recoge con
/// `sys_net_recibir`, filtra por este EtherType y decodifica el payload
/// como [`MensajeAsistente`] postcard.
///
/// Mantenerlo distinto de 0x88B5 evita que el demuxer intente
/// deserializar el payload como `MensajeAkasha` y lo descarte como
/// `PayloadInvalido` antes de pasarlo al usuario.
pub const ETHERTYPE_ASISTENTE: u16 = 0x88B6;
/// Acción que el LLM (vía el puente) propone al asistente. La app pinta
/// la propuesta, el humano decide. Acciones potentes (re-anclar manifiesto,
/// cambiar configuración) referencian objetos del grafo por `Hash` — el
/// puente los preparó y los ingestó vía Akasha; el kernel los verifica al
/// aplicar.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub enum AccionPropuesta {
/// Lanzar la app `plantilla`-ésima del manifiesto. Equivalente al
/// `Mando::LanzarFila` del launcher, pero dirigido por LLM.
LanzarApp { plantilla: u32 },
/// Re-anclar el manifiesto al hash propuesto. Requiere firma humana
/// vía `daemon-firma` antes de invocar `sys_manifiesto_proponer`.
InstalarApp { manifiesto_propuesto: Hash },
/// Cambiar la `Configuracion` activa al hash propuesto. Mismo flujo
/// de firma humana que `InstalarApp`.
CambiarConfiguracion { config_propuesta: Hash },
/// Sin efecto sobre el sistema — el LLM nada más anota algo para que
/// el humano lo lea. Útil para responder preguntas tipo "¿cuántas
/// apps tengo?" sin disparar acciones.
Notar { texto: String },
}
/// Contexto del estado actual del nodo wawa que la app envía al puente
/// junto con la consulta. Permite que el LLM responda con info concreta
/// (nombres de apps reales, configuración activa) en lugar de a ciegas.
/// Lo que se incluye está acotado deliberadamente — más campos = más
/// tokens en el system prompt = más coste.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
pub struct Contexto {
/// Nombres de las apps del manifiesto vivo, en el orden del catálogo
/// del launcher. El LLM puede usar `LanzarApp { plantilla: i }` con
/// el índice de la fila correspondiente.
pub apps: Vec<String>,
/// Hash del manifiesto vigente. Permite que el puente detecte si su
/// caché local quedó stale (otro nodo re-ancló en paralelo) y
/// rerequiera contexto fresco.
pub manifiesto_actual: Option<Hash>,
/// Hash de la `Configuracion` activa, si la hay. `None` si el
/// manifiesto no enlaza ninguna.
pub configuracion_activa: Option<Hash>,
}
/// Un mensaje sobre el canal `CANAL_ASISTENTE`. La app y el puente
/// hablan exclusivamente este enum — un atacante que envíe payload ajeno
/// al canal se queda sin decodificar (postcard rechaza el frame).
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum MensajeAsistente {
/// La app pregunta. El puente lo retransmite al LLM. `id` correlaciona
/// request/response — un puente sirviendo varios nodos los distingue
/// por id ANTES de cualquier RTT extra.
Consulta {
id: u64,
prompt: String,
contexto: Contexto,
},
/// El puente responde con una propuesta interpretada del LLM.
/// `confianza` es la decisión del puente — `1.0` si el LLM produjo
/// JSON limpio y la acción está en la lista blanca; valores menores
/// si tuvo que adivinar o si el parseo fue parcial.
Propuesta {
id: u64,
accion: AccionPropuesta,
explicacion: String,
confianza: f32,
},
/// El puente reporta un fallo de transporte o parseo. El `id`
/// correlaciona contra la consulta original; el `motivo` es un string
/// libre que la app pinta al humano.
Error { id: u64, motivo: String },
}
impl MensajeAsistente {
/// Serializa con postcard. El kernel lo manda por Akasha; el puente
/// lo recibe y deserializa.
pub fn serializar(&self) -> Result<Vec<u8>, postcard::Error> {
postcard::to_allocvec(self)
}
/// Deserializa desde bytes. Si el frame está truncado o el canal
/// trajo basura ajena, devuelve error sin tocar `self`.
pub fn deserializar(bytes: &[u8]) -> Result<Self, postcard::Error> {
postcard::from_bytes(bytes)
}
}
// =============================================================================
// Protocolo "cable" del asistente — alfabeto minimo sin alloc
// -----------------------------------------------------------------------------
// `MensajeAsistente` (arriba) usa `String` y `Vec` para empaquetar prompts
// y explicaciones de longitud arbitraria. La app `asistente.wasm` corre en
// no_std SIN alloc — no puede construir esos tipos. Para el cable definimos
// un alfabeto minimo que cabe en arrays fijos: cabecera de 12 bytes
// (canal + tipo + id) + payload de longitud inferida del frame.
//
// El puente Linux traduce entre el rico `MensajeAsistente` (que usa para
// hablar con pluma-llm) y este protocolo cable (que viaja por Akasha).
// =============================================================================
/// Tamaño en bytes de la cabecera del protocolo cable.
/// `canal (2) + tipo (2) + id (8) = 12`.
pub const TAM_CABECERA_CABLE: usize = 12;
/// Tipos de mensaje sobre el cable del asistente. Discriminante u16 big
/// endian estable — los lectores binarios pueden grep por estos valores.
#[repr(u16)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TipoCable {
/// Consulta de la app al puente. Payload = bytes ASCII del prompt
/// (sin nul terminator — la longitud se infiere del frame).
Consulta = 1,
/// Propuesta del puente del tipo `Notar` (la IA contestó algo
/// informativo). Payload = bytes ASCII del texto.
PropuestaNotar = 2,
/// Propuesta del puente del tipo `LanzarApp`. Payload = u32 BE con
/// el índice de plantilla a lanzar (4 bytes).
PropuestaLanzarApp = 3,
/// Propuesta de re-anclar el manifiesto. Payload = 32 bytes del hash.
PropuestaInstalarApp = 4,
/// Propuesta de cambiar la configuración activa. Payload = 32 bytes
/// del hash de la nueva configuración.
PropuestaCambiarConfig = 5,
/// Error reportado por el puente (transporte, rechazo del LLM,
/// parseo). Payload = bytes ASCII del motivo.
Error = 6,
/// Fase 60 v4 :: la app `asistente.wasm` pide la firma humana de un
/// objeto (manifiesto/configuración). El puente lo relaya al
/// `wawactl daemon-firma` por su transporte normal (PTY/virtio-console)
/// y devuelve la firma en un [`TipoCable::Firma`]. Payload:
/// `[tipo_obj: u8, hash: [u8; 32]]` = 33 bytes.
/// - `tipo_obj` = [`TIPO_OBJETO_CUADERNO`] (1) si el hash es de
/// manifiesto/cuaderno (legacy `wawa::sign_request::`).
/// - `tipo_obj` = [`TIPO_OBJETO_CONFIGURACION`] (2) si es de
/// configuración (`wawa::sign_config::`).
/// Otros valores son rechazados por el puente con un `TipoCable::Error`.
RequestFirma = 7,
/// Fase 60 v4 :: respuesta del puente con la firma humana ya
/// autorizada por el operador (via `daemon-firma`). Payload:
/// `[slot: u8, firma: [u8; 64]]` = 65 bytes. `slot` es 0/1/2 — el
/// índice dentro de `AGORA_AUTH_RING` que el operador eligió al
/// arrancar el demonio. El asistente.wasm construye el sobre
/// firmado y, cuando tenga PERMISO_RAIZ (hito 6), invoca
/// `sys_manifiesto_proponer`.
Firma = 8,
}
/// FASE 60 v4 :: discriminantes del primer byte del payload de
/// `TipoCable::RequestFirma`. El puente los mapea al prefijo correcto
/// para `daemon-firma` (`wawa::sign_request::` vs `wawa::sign_config::`).
/// El mismo discriminante puede aparecer en logs del operador.
pub const TIPO_OBJETO_CUADERNO: u8 = 1;
/// Como [`TIPO_OBJETO_CUADERNO`] pero para configuraciones. Ver Fase 60 v2
/// del `wawactl daemon-firma` — el prefijo correspondiente es
/// `wawa::sign_config::`.
pub const TIPO_OBJETO_CONFIGURACION: u8 = 2;
impl TipoCable {
/// Traduce un u16 al variant correspondiente o `None` si es
/// desconocido (el cable trajo un tipo no registrado).
pub fn de_u16(v: u16) -> Option<Self> {
match v {
1 => Some(Self::Consulta),
2 => Some(Self::PropuestaNotar),
3 => Some(Self::PropuestaLanzarApp),
4 => Some(Self::PropuestaInstalarApp),
5 => Some(Self::PropuestaCambiarConfig),
6 => Some(Self::Error),
7 => Some(Self::RequestFirma),
8 => Some(Self::Firma),
_ => None,
}
}
}
/// Escribe la cabecera del cable en `out`. Devuelve la longitud escrita
/// (siempre `TAM_CABECERA_CABLE`) o `None` si `out` no cabe — el caller
/// reserva el buffer apropiado.
pub fn escribir_cabecera_cable(out: &mut [u8], tipo: TipoCable, id: u64) -> Option<usize> {
if out.len() < TAM_CABECERA_CABLE {
return None;
}
out[0..2].copy_from_slice(&CANAL_ASISTENTE.to_be_bytes());
out[2..4].copy_from_slice(&(tipo as u16).to_be_bytes());
out[4..12].copy_from_slice(&id.to_be_bytes());
Some(TAM_CABECERA_CABLE)
}
/// Lee la cabecera del cable y verifica que el canal sea el del
/// asistente. Devuelve `(tipo, id)` o `None` si los bytes son
/// insuficientes, el canal no coincide o el tipo es desconocido. El
/// llamante interpreta `&bytes[TAM_CABECERA_CABLE..]` según `tipo`.
pub fn leer_cabecera_cable(bytes: &[u8]) -> Option<(TipoCable, u64)> {
if bytes.len() < TAM_CABECERA_CABLE {
return None;
}
let canal = u16::from_be_bytes([bytes[0], bytes[1]]);
if canal != CANAL_ASISTENTE {
return None;
}
let tipo_raw = u16::from_be_bytes([bytes[2], bytes[3]]);
let tipo = TipoCable::de_u16(tipo_raw)?;
let id = u64::from_be_bytes([
bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11],
]);
Some((tipo, id))
}
+163
View File
@@ -0,0 +1,163 @@
// =============================================================================
// Constantes del format en disco
// =============================================================================
/// Firma magica del superbloque — «RENASer GRaFo». Distingue un disco de
/// renaser de uno virgen o ajeno.
pub const MAGIA: [u8; 8] = *b"RENASGRF";
/// Version del format del superbloque en disco. Un disco con otra version se
/// reformatea al arrancar. v3 (Fase 24) — el superbloque porta `log_inicio`:
/// el sector donde arranca el log activo. El compactador semantico copia el
/// set alcanzable a una zona limpia del disco y reanca el superbloque a un
/// nuevo `log_inicio` en una sola escritura atomica. v2 (Fase 7) ya portaba
/// el ancla `manifiesto`, gemela de `raiz`.
pub const VERSION_SUPERBLOQUE: u32 = 3;
/// Version del format del manifiesto serializado. Independiente de la del
/// superbloque: el manifiesto es un objeto del grafo, no una estructura fija
/// del disco. v4 — cada `EntradaApp` declara su `permisos: u32`: un bitfield
/// que dicta QUE capacidades el kernel enlaza en su `Linker` de wasmi. Las
/// capacidades sensibles (red, raiz, altavoz, configuracion, escritura del
/// grafo) no se REGISTRAN si el bit no esta puesto: la frontera es fisica,
/// no chequeada en runtime. No hay escalada porque no hay tabla que escalar.
///
/// v5 (Fase 67 / WAWA §14.1.3) — cada `EntradaApp` gana `concesion:
/// Option<Hash>`: el hash de una [`ConcesionCapacidad`] firmada por el
/// `AGORA_AUTH_RING` sobre `(bytecode, permisos)`. Cuando una app la declara,
/// el kernel toma la INTERSECCION [`permisos_efectivos`]`(declarados,
/// concedidos)` — un manifiesto re-firmado ya no puede escalar un binario mas
/// alla de lo que su concesion, atada a su hash, autoriza. Si `concesion` es
/// `None` no hay techo per-bytecode: gobierna la firma del manifiesto (camino
/// legacy, rollout escalonado — ver `SDD-capacidades.md` §3.6).
///
/// CORTE DE FORMATO: `postcard` NO es autodescriptivo, asi que cada campo nuevo
/// rompe el wire de la version previa. Un disco viejo NO deserializa — el guardia
/// de version (`Manifiesto::deserializar` exige `version == VERSION_MANIFIESTO`)
/// lo rechaza y exige re-sembrar el genesis. En la practica el operador re-forja
/// la imagen en cada `cargo run -p boot`, asi que la genesis nace limpia.
/// v5→v6 (2026-05-30): agrega `overlay_revocacion: Option<Hash>` para el plano de
/// control del SDD-rotacion-revocacion §4.
/// v6→v7 (2026-06-03): agrega `marco: Option<Hash>` para persistir el marco del
/// escritorio (`pata`) activo entre reinicios — Fase 9 del SDD de pata.
pub const VERSION_MANIFIESTO: u32 = 7;
/// Version del format de la `Configuracion` serializada. La configuracion es
/// otro objeto del grafo (idioma + paleta); el manifiesto la enlaza por hash.
/// v1 inaugura el modelo: cambiarla es engendrar un nodo nuevo y reanclar.
pub const VERSION_CONFIGURACION: u32 = 1;
/// Version del format del canal de release serializado. Independiente del
/// manifiesto: un canal es otro objeto del grafo, con su propia historia de
/// raices recomendadas. v1 inaugura el modelo de distribucion.
pub const VERSION_CANAL: u32 = 1;
/// Techo del nombre de un canal, en bytes. Acota la cabecera serializada y
/// fuerza a que los canales se nombren cortos —`estable`, `beta`, `dev`,
/// `cofradia-tal`—. Quien intente registrar un canal con un nombre mas largo
/// se topa con un error de deserializacion.
pub const NOMBRE_CANAL_LIMITE: usize = 64;
/// Techo del tamaño de un objeto serializado: 1 MiB. Acota los buferes de E/S
/// y permite descartar un registro corrupto sin leer un disparate.
pub const MAX_OBJETO: usize = 1024 * 1024;
/// Tamaño de un sector del disco, en bytes. El log se traza en multiplos de
/// esta unidad — la misma que expone el transporte virtio-blk.
pub const TAM_SECTOR: usize = 512;
/// El identificador de un objeto: el hash BLAKE3 de su forma serializada. En
/// un almacen direccionado por contenido, la identidad ES el contenido.
pub type Hash = [u8; 32];
// =============================================================================
// CodigoError — el lenguaje de los retornos de syscall, sin alucinaciones
// -----------------------------------------------------------------------------
// Los retornos negativos de las capacidades `sys_*` no son enteros opacos:
// son variantes nombradas, fuertemente tipadas, con un valor i32 estable.
// El kernel emite `CodigoError::X as i32`; el userspace compara contra el
// mismo numero. Anadir una variante NUEVA es engendrar un valor nuevo (las
// existentes jamas se renumeran), de modo que un binario viejo y un kernel
// nuevo siguen hablando el mismo idioma para los codigos que ambos conocen.
//
// Los retornos POSITIVOS de algunas capacidades son cuentas de bytes copiados
// —no errores—; por eso `Ok = 0` y todos los errores caen en negativos. La
// comparacion habitual del userspace queda intacta: `< 0` ya es "fallo", y
// el codigo concreto lo describe.
// =============================================================================
/// El catalogo de retornos negativos de las capacidades del kernel. Un solo
/// nombre por causa: nadie ha de inventarse una semantica nueva para el -1.
///
/// Mantenido en `format` porque viaja por TRES fronteras: el kernel lo emite,
/// el explorador (host-side) lo lee de las trazas, y los modulos WASM lo
/// reciben. La crate ya es no_std y la traen ambos lados sin friccion.
#[repr(i32)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CodigoError {
/// Operacion completada sin novedad. Las capacidades que devuelven un
/// conteo de bytes usan tambien `0` para "no habia nada que entregar"
/// (lectura sin frame, sin evento, sin estado previo); el contexto del
/// retorno positivo distingue ambos casos.
Ok = 0,
/// El recurso solicitado no esta presente: un objeto que no esta en el
/// grafo, la tarjeta de red sin montar, una app sin estado previo, una
/// cola del puntero o el teclado vacia. Tambien lo emite un guardar
/// que no encontro su ranura.
Ausente = -1,
/// La capacidad recibida en `salida` no cubre los datos a copiar. La app
/// debe llamar con un bufer mas amplio; el kernel no escribio nada en el
/// destino.
CapacidadInsuficiente = -2,
/// El subsistema de almacenamiento (virtio-blk, log de objetos, censo del
/// manifiesto) fallo al servir o anclar el objeto. NO es culpa del modulo,
/// pero la operacion no pudo completarse.
AlmacenamientoFallo = -3,
/// La app no tiene el FOCO del compositor en este fotograma y la capacidad
/// solo se honra para la ventana enfocada — por ejemplo, cambiar la
/// `Configuracion` del escritorio. Reintentar cuando la app sea la
/// destinataria del teclado.
SinFoco = -4,
/// El envio al dispositivo (driver de red, altavoz) fracaso. Lo emite el
/// driver y la capacidad lo propaga: no hay rastro de bytes residuales en
/// el hardware.
EnvioFallo = -5,
/// Cuota de recurso saturada para esta app en este fotograma: hay un
/// limite blando que protege un recurso fisico (DMA, descriptores de un
/// anillo virtio) y la app lo alcanzo. El kernel NO entrega la
/// operacion ni avanza el contador; la app ha de retirarse y volver a
/// intentar en su proximo `tick` —cuando la IRQ del hardware haya
/// liberado los descriptores que tenia retenidos—. Es BACK-PRESSURE
/// cooperativa: el equivalente de un `Poll::Pending` que cabe en un
/// codigo de retorno entero. Distingue a una autodefensa del kernel
/// frente al codigo de la app de un fallo del propio almacenamiento.
Saturado = -6,
/// El payload que la app entrego al kernel decodifica pero esta FUERA
/// del dominio que la capacidad acepta — un codigo de idioma que no es
/// letras ASCII, una paleta cuyos canales suman cero, un campo
/// inconsistente con su contexto. Distinto de `Ausente` (recurso
/// inexistente) y `CapacidadInsuficiente` (bufer corto): aqui los
/// bytes llegaron pero su SIGNIFICADO los descalifica. La app ha
/// de reconstruir su entrada con valores legitimos antes de reintentar.
PayloadInvalido = -7,
}
impl CodigoError {
/// Convierte el codigo a su forma de cable i32 — la unica que el userspace
/// recibe. `as i32` directo, sin trampa: el `#[repr(i32)]` fija el valor.
pub const fn como_i32(self) -> i32 {
self as i32
}
}
/// La identidad de un autor agora: una clave publica Ed25519, 32 bytes. Quien
/// firma una raiz de canal se identifica con esto. `format` no valida la
/// firma —no enlaza ninguna primitiva criptografica—; solo declara su forma.
/// La verificacion vive en `agora` (o en `firma`), donde corresponde.
pub type AgoraId = [u8; 32];
/// Una firma Ed25519, 64 bytes. La produce `agora` sobre el mensaje canonico
/// que devuelve [`mensaje_a_firmar`]. `format` la transporta y la deja a quien
/// pueda verificarla.
pub type Firma = [u8; 64];
+222
View File
@@ -0,0 +1,222 @@
use super::*;
// =============================================================================
// Fase 67 :: la CONCESION DE CAPACIDAD — "que binario puede hacer que", firmado
// -----------------------------------------------------------------------------
// Hoy los permisos de una app viven en su `EntradaApp` del manifiesto: el
// manifiesto firmado dice "el bytecode X corre con permisos P". El binding es
// tan fuerte como el manifiesto — re-firmar un manifiesto nuevo basta para
// darle al MISMO binario permisos distintos. La concesion eleva ese binding a
// un hecho INDEPENDIENTE del manifiesto: una firma Ed25519 de una llave del
// `AGORA_AUTH_RING` sobre el par `(hash_bytecode, permisos)`. La firma viaja
// con el binario y NINGUN manifiesto puede escalar un binario mas alla de lo
// que su concesion autoriza —el kernel toma la INTERSECCION, ver
// [`permisos_efectivos`]—. Gemelo estructural de [`ManifiestoFirmado`]: la
// verificacion comparte el camino Ring 0 zero-alloc de `ed25519-compact`, pero
// el mensaje firmado es [`mensaje_capacidad`], no el hash pelado.
// =============================================================================
/// Una concesion de capacidad firmada: liga inmutablemente un bytecode (por su
/// hash BLAKE3) a un bitfield de permisos, respaldada por la firma de una
/// identidad soberana. Es un objeto del grafo (direccionado por contenido) que
/// un `EntradaApp` referencia; el kernel la verifica contra el `AGORA_AUTH_RING`
/// antes de enlazar capacidad alguna.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct ConcesionCapacidad {
/// Hash BLAKE3 del objeto-bytecode WASM al que esta concesion aplica. La
/// firma lo cubre: una concesion para el bytecode X jamas vale para Y.
pub bytecode: Hash,
/// Bitfield de permisos que esta concesion AUTORIZA para ese bytecode (ver
/// [`Permisos`] y las constantes `PERMISO_*`). Subir un bit invalida la firma.
pub permisos: Permisos,
/// Llave publica Ed25519 de quien concede. El kernel exige que habite el
/// `AGORA_AUTH_RING` antes de gastar un ciclo en criptografia.
pub autor: AgoraId,
/// Firma Ed25519 sobre [`mensaje_capacidad`]`(bytecode, permisos)`.
#[serde(with = "BigArray")]
pub firma: Firma,
}
impl ConcesionCapacidad {
/// Serializa la concesion a `postcard` — la carga util del objeto del grafo
/// que la aloja.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self)
.map_err(|_| "concesion_capacidad :: serializacion fallida")
}
/// Reconstruye una concesion desde su forma binaria. Tolera bytes
/// sobrantes tras la estructura — el relleno del registro.
pub fn deserializar(bytes: &[u8]) -> Result<ConcesionCapacidad, &'static str> {
postcard::take_from_bytes::<ConcesionCapacidad>(bytes)
.map(|(c, _)| c)
.map_err(|_| "concesion_capacidad :: deserializacion fallida")
}
}
// =============================================================================
// Overlay de revocacion del plano de CONTROL (SDD-rotacion-revocacion §4)
// -----------------------------------------------------------------------------
// El AGORA_AUTH_RING del kernel es `const` en `.rodata`: rotar el ancla = reflash
// deliberado. Pero entre reflasheos una clave soberana puede filtrarse, y esperar
// al re-forjado deja una ventana abierta. El overlay la cierra: un objeto del
// grafo, anclado por el manifiesto (`Manifiesto::overlay_revocacion`), que lista
// revocaciones firmadas M-of-N por el RESTO del anillo. El kernel lo lee FRESH en
// el arranque y deniega en `autor_en_anillo` toda clave del anillo revocada.
//
// Tipos `no_std + alloc`: el kernel los deserializa (postcard) y los verifica con
// `claves::verificar_revocacion` sobre el canonico de `mensaje_revocacion_clave`.
// El productor host-side (`agora-cli wawa revocar`) emite el mismo wire.
//
// TIEMPO: el kernel hoy lleva ticks PIT, no wall-clock. Aplica la revocacion
// mientras este ANCLADA (fail-closed, deny-wins); `vence_en` entra en el canonico
// firmado pero la auto-caducidad temporal espera un RTC. Des-revocar = anclar un
// overlay nuevo sin esa entrada (gemelo de mover el puntero de `configuracion`).
// =============================================================================
/// Version del format del [`OverlayRevocacion`] serializado.
pub const VERSION_OVERLAY: u32 = 1;
/// Una firma individual dentro de una [`RevocacionFirmada`]: la pubkey del
/// firmante y su firma Ed25519 sobre el canonico de la revocacion. Espejo
/// minimo de `agora_core::SingleSig` (sin el `IdentityId` redundante: el kernel
/// re-deriva la autoridad comparando la pubkey contra el `AGORA_AUTH_RING`).
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct FirmaRevocacion {
/// Clave publica Ed25519 del firmante (un miembro del anillo, en control).
pub autor: AgoraId,
/// Firma Ed25519 sobre [`mensaje_revocacion_clave`]`(objetivo, motivo,
/// emitida_en, vence_en)`.
#[serde(with = "BigArray")]
pub firma: Firma,
}
/// Una revocacion de clave firmada por un quorum, en forma de wire para el
/// overlay. Espejo de `agora_core::Revocation` aplanado para el kernel: el
/// `motivo` es el discriminante estable de `RevReason` (0=Compromised,
/// 1=Retired, 2=Superseded) y `firmantes` es la multifirma desnuda.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct RevocacionFirmada {
/// La clave que se revoca (en el plano de control, una del anillo).
pub objetivo: AgoraId,
/// Motivo (entra en el canonico firmado): 0=Compromised, 1=Retired, 2=Superseded.
pub motivo: u8,
/// Segundos UNIX desde cuando rige.
pub emitida_en: u64,
/// `None` ⇒ permanente; `Some(t)` ⇒ suspension hasta `t` (auto-caducidad
/// pendiente de RTC en el kernel — ver nota de tiempo arriba).
pub vence_en: Option<u64>,
/// Las firmas del quorum autorizador.
pub firmantes: Vec<FirmaRevocacion>,
}
/// El overlay de revocacion: la lista de revocaciones que el kernel consulta al
/// arrancar. Objeto del grafo direccionado por contenido; el manifiesto guarda
/// su hash en `overlay_revocacion`.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
pub struct OverlayRevocacion {
/// Version del format — debe ser [`VERSION_OVERLAY`].
pub version: u32,
/// Las revocaciones vigentes. El kernel aplica las que apunten a un slot del
/// anillo y reunan el quorum; ignora el resto (no son su jurisdiccion).
pub revocaciones: Vec<RevocacionFirmada>,
}
impl OverlayRevocacion {
/// Serializa el overlay a `postcard` — la carga util del objeto del grafo
/// que lo aloja.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "overlay_revocacion :: serializacion fallida")
}
/// Reconstruye un overlay desde su forma binaria. Rechaza una version de
/// format desconocida en lugar de malinterpretarla. Tolera bytes sobrantes
/// tras la estructura — el relleno del registro.
pub fn deserializar(bytes: &[u8]) -> Result<OverlayRevocacion, &'static str> {
let (overlay, _) = postcard::take_from_bytes::<OverlayRevocacion>(bytes)
.map_err(|_| "overlay_revocacion :: deserializacion fallida")?;
if overlay.version != VERSION_OVERLAY {
return Err("overlay_revocacion :: version de format desconocida");
}
Ok(overlay)
}
}
// =============================================================================
// Fase 37 :: el sello criptografico del CUADERNO SOBERANO
// -----------------------------------------------------------------------------
// La integridad de un cuaderno —un nodo del grafo cuyo payload es
// `Vec<CeldaWawa>` (Fase 43, modelo unificado)— se proteje en dos planos:
//
// * Localmente, el direccionamiento por contenido garantiza que un
// bit alterado en cualquier celda cambia el hash del cuaderno
// —y ese hash es la identidad del nodo en el almacen—.
// * En la red capa-2 (Akasha), eso no basta: un peer hostil puede
// reescribir el cuaderno entero y reanunciarlo con su propio hash.
// Para que el sistema reconozca un cuaderno como SOBERANO del
// operador local, el peer ha de adjuntar una firma Ed25519 del
// cuaderno_raiz_hash producida con la clave privada que pertenece
// a la `AGORA_PUBLIC_KEY_LOCAL` empotrada en el binario del kernel.
//
// Gemelo estructural de `ManifiestoFirmado`: la verificacion comparte
// el camino Ring 0 zero-alloc de `ed25519-compact`.
// =============================================================================
/// Sobre criptografico de un cuaderno: vincula su `hash` con un autor y
/// una firma Ed25519. Sin este sobre, un cuaderno es solo un nodo mas
/// del grafo — con el sobre, queda anclado como SOBERANO al usuario
/// que firmo, y el kernel lo distingue de cualquier otro nodo cuaderno
/// que viaje por la red.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct CuadernoFirmado {
/// Hash BLAKE3 del cuaderno propuesto. El payload del cuaderno es
/// `Vec<CeldaWawa>` serializado con postcard; este hash es el
/// resumen criptografico que va a engrapar la firma.
pub cuaderno_raiz_hash: Hash,
/// Llave publica Ed25519 del autor. El kernel la compara contra
/// `AGORA_PUBLIC_KEY_LOCAL` antes de gastar un ciclo en criptografia
/// — un autor ajeno cae con `CapacidadInsuficiente`.
pub autor: AgoraId,
/// Firma Ed25519 sobre los 32 bytes de `cuaderno_raiz_hash`.
#[serde(with = "BigArray")]
pub firma: Firma,
}
impl CuadernoFirmado {
/// Serializa el sobre a su forma binaria `postcard`. La forma cruda
/// ocupa 32 + 32 + 64 = 128 bytes; postcard agrega un preludio
/// minusculo (longitudes varint) que mantiene el sobre bajo 140 B.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self)
.map_err(|_| "cuaderno_firmado :: serializacion fallida")
}
/// Reconstruye un sobre desde su forma binaria. Tolera bytes
/// sobrantes tras la estructura — el relleno del registro o el
/// padding del payload del syscall.
pub fn deserializar(bytes: &[u8]) -> Result<CuadernoFirmado, &'static str> {
postcard::take_from_bytes::<CuadernoFirmado>(bytes)
.map(|(cf, _)| cf)
.map_err(|_| "cuaderno_firmado :: deserializacion fallida")
}
}
/// Una entrada del historial de un canal: una raiz de manifiesto, el instante
/// en que el autor la propuso, y la firma Ed25519 con la que el autor la
/// respalda. La firma se calcula sobre [`mensaje_a_firmar`].
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct RaizFirmada {
/// Instante en que el autor propuso esta raiz, segundos desde UNIX epoch.
/// Un receptor desconfia de raices con timestamp futuro mas alla de un
/// margen razonable —proteccion barata contra anuncios envenenados—.
pub timestamp: u64,
/// El hash del [`Manifiesto`] que esta raiz inaugura. Re-anclar el
/// superbloque a este hash es, literalmente, "actualizar a esta version".
pub raiz_manifiesto: Hash,
/// La firma Ed25519 del autor del canal sobre [`mensaje_a_firmar`].
/// `serde` no derivara `Deserialize` para `[u8; 64]` sin ayuda —su soporte
/// directo se detiene en 32 bytes—; `serde-big-array` cierra ese hueco.
#[serde(with = "BigArray")]
pub firma: Firma,
}
+400
View File
@@ -0,0 +1,400 @@
use super::*;
// =============================================================================
// Fase 33/43 :: el almacen semantico del cuaderno (modelo unificado)
// -----------------------------------------------------------------------------
// Un CUADERNO de Wawa es un nodo del grafo cuyo payload `postcard` es un
// `Vec<CeldaWawa>`. Cada `CeldaWawa` empaqueta TODA la informacion de un
// eslabon del calculo en una sola estructura inmutable:
//
// * `id_secuencial` :: indice lineal en el cuaderno.
// * `fuente_hash` :: hash del texto Forth o token `@<hash>` literal.
// * `binario_hash` :: hash del modulo WASM materializado (None si
// la compilacion fallo).
// * `ultimo_retorno` :: el i32 que la sub-jaula efimera devolvio
// (None si nunca se ejecuto).
// * `marca_error` :: bandera atomica: hubo TRAP, OUT_OF_FUEL,
// PAYLOAD_INVALIDO, o cualquier otra falla.
//
// La fusion (Fase 43) elimina el enum heredado `TipoCeldaWawa` con sus
// tres variantes flat — el modelo estructurado es mas honesto con la
// semantica del cuaderno y converge bit-a-bit con la representacion
// del motor Linux del ecosistema Pluma (`pluma-notebook-core`), que
// re-exporta esta misma struct para hablar el mismo idioma en host y
// en el silicio.
//
// Las aristas del nodo (los `hijos` que el almacen registra al insertar)
// son: el CUADERNO PREVIO cuando existe (arista ancestral, Fase 47),
// `fuente_hash` siempre, `binario_hash` cuando esta presente. El
// direccionamiento por contenido hace EXPLICITAS las dependencias y
// el cuaderno arrastra criptograficamente todo su tejido de causas y
// efectos. Con la Fase 47, cada cuaderno apunta a su predecesor por
// hash — el historial es una cadena recorrible por el Walker.
//
// Postcard-amigable: campos primitivos + `Option<T>` + arrays alineados.
// La deserializacion del cuaderno no allocea fuera del `Vec` principal.
// =============================================================================
/// El eslabon canonico de un cuaderno (Fase 43). Reemplaza al enum
/// `TipoCeldaWawa` de la Fase 33: en lugar de tres variantes flat que
/// solo el orden del `Vec` ataba a una "celda", aqui CADA `CeldaWawa`
/// es una celda completa con todos sus eslabones bundled. Bit-compatible
/// con `pluma_notebook_core::CeldaWawa` (re-export en el motor Linux).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct CeldaWawa {
/// Indice lineal en el cuaderno — orden de presentacion. Empieza
/// en 0 y crece con cada celda exitosamente registrada.
pub id_secuencial: u32,
/// Hash del texto fuente: ASCII Forth tecleado por el humano, o
/// la cadena literal `@<64-hex>` para celdas macro-importadas
/// (Fase 36, Cross-App Bridge). Siempre presente — una celda sin
/// fuente es incoherente con el modelo.
pub fuente_hash: Hash,
/// Hash del modulo WASM materializado por `forth-emisor` (o
/// importado del grafo via `@<hash>`). `None` cuando la compilacion
/// fallo, la sintaxis Forth fue rechazada, o la vinculacion macro
/// no se logro — el binario no llego a inscribirse.
pub binario_hash: Option<Hash>,
/// El i32 que la sub-jaula efimera (Fase 32) devolvio en su ultima
/// ejecucion. `None` cuando la celda nunca corrio (sin binario, o
/// el despacho dinamico ni siquiera arranco). Un valor negativo
/// en `[-7, -1]` reservado en `CodigoError` codifica fallas
/// controladas; valores fuera de ese rango son resultados legitimos.
pub ultimo_retorno: Option<i32>,
/// Bandera atomica de error: `true` si CUALQUIER eslabon de la
/// cadena (compilacion, registro v2, ejecucion dinamica, anclaje
/// de cuaderno) devolvio fallo. El renderer la usa para teñir la
/// celda de amarillo palido sin enterrar el valor del retorno —
/// `marca_error && ultimo_retorno == Some(-7)` significa
/// "ejecutada, fallida con trap"; `marca_error && ultimo_retorno
/// == None` significa "ni siquiera corrio".
pub marca_error: bool,
}
/// Serializa una secuencia de celdas a `postcard` — la forma que el
/// kernel inscribe como payload del nodo cuaderno. Centralizada aqui
/// para que el kernel no tenga que declarar `postcard` directamente
/// (ya lo hereda transitivamente via `format`).
pub fn serializar_celdas(celdas: &[CeldaWawa]) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(celdas).map_err(|_| "celdas :: serializacion fallida")
}
/// Reconstruye la secuencia de celdas desde el payload de un nodo cuaderno.
/// Tolera bytes sobrantes — el relleno del registro vive despues del payload.
pub fn deserializar_celdas(bytes: &[u8]) -> Result<Vec<CeldaWawa>, &'static str> {
postcard::take_from_bytes::<Vec<CeldaWawa>>(bytes)
.map(|(celdas, _)| celdas)
.map_err(|_| "celdas :: deserializacion fallida")
}
// =============================================================================
// (De)serializacion — la forma binaria que viaja al disco
// =============================================================================
impl Objeto {
/// Serializa el objeto a su forma binaria `postcard`.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "objeto :: serializacion fallida")
}
/// Reconstruye un objeto desde su forma binaria. Tolera bytes sobrantes
/// tras el objeto —el relleno del registro—: solo consume su prefijo.
pub fn deserializar(bytes: &[u8]) -> Result<Objeto, &'static str> {
postcard::take_from_bytes::<Objeto>(bytes)
.map(|(objeto, _)| objeto)
.map_err(|_| "objeto :: deserializacion fallida")
}
}
// =============================================================================
// Fase 66 :: Árbol/Blob — el monorepo como grafo
// -----------------------------------------------------------------------------
// El grafo direccionado por contenido ES el modelo de objetos de git. Esta
// capa lo hace explícito para que un árbol de directorios viva en el grafo:
//
// * BLOB :: el contenido de un archivo. Es un `Objeto { datos: bytes,
// hijos: [] }` — sin estructura, solo bytes direccionados por
// su hash. Archivos idénticos comparten un solo blob (dedup
// por contenido, gratis).
// * ÁRBOL :: el contenido de un directorio. Un `Objeto` cuyo `datos` es
// un `Arbol` postcard (la lista de entradas: nombre + modo +
// hash) y cuyos `hijos` son los hashes de esas entradas — así
// el MARK del GC del kernel alcanza todo el subárbol siguiendo
// `hijos`, SIN tener que entender el format `Arbol`.
//
// Las entradas de un árbol van ORDENADAS por nombre: mismo contenido de
// directorio => mismo árbol serializado => mismo hash. Determinismo total, la
// base de la dedup y de la verificación. Un repositorio entero colapsa a UN
// hash raíz; dos commits que solo tocan un archivo comparten todo el resto del
// árbol (estructura compartida, como git).
// =============================================================================
/// Version del format de un `Arbol`.
pub const VERSION_ARBOL: u32 = 1;
/// Qué clase de objeto referencia una entrada de árbol. Espeja los modos de
/// git (archivo / archivo+x / symlink / directorio). Variantes AÑADIDAS AL
/// FINAL: los tags `postcard` se asignan por orden y mover una romperia árboles
/// ya serializados.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ModoEntrada {
/// El hash apunta a un archivo regular (blob plano o índice de trozos).
Archivo,
/// El hash apunta a otro ÁRBOL (subdirectorio).
Directorio,
/// Como `Archivo` pero con bit de ejecución (un script, un binario).
Ejecutable,
/// El hash apunta a un blob cuyo contenido es el DESTINO del enlace
/// simbólico (la ruta a la que apunta), en UTF-8.
Symlink,
}
impl ModoEntrada {
/// `true` si el modo referencia CONTENIDO de archivo (blob/índice): un
/// archivo regular o un ejecutable. `Symlink` y `Directorio` no.
pub fn es_archivo(&self) -> bool {
matches!(self, ModoEntrada::Archivo | ModoEntrada::Ejecutable)
}
}
/// Una entrada de un árbol: un nombre dentro del directorio + el modo + el hash
/// del objeto que la realiza (un blob si `Archivo`, un árbol si `Directorio`).
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct EntradaArbol {
/// Nombre del archivo/subdirectorio (sin separadores de ruta).
pub nombre: String,
/// Si el hash apunta a un blob o a un subárbol.
pub modo: ModoEntrada,
/// Hash del objeto (blob o árbol) que esta entrada referencia.
pub hash: Hash,
}
/// Un árbol: el contenido de un directorio, como lista ordenada de entradas.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Arbol {
/// Version del format — debe ser [`VERSION_ARBOL`].
pub version: u32,
/// Entradas ORDENADAS por nombre (invariante que `objeto_arbol` impone).
pub entradas: Vec<EntradaArbol>,
}
impl Arbol {
/// Serializa el árbol a su forma `postcard` —la carga útil del objeto que
/// lo aloja—.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "arbol :: serializacion fallida")
}
/// Reconstruye un árbol desde la carga útil de su objeto. Rechaza una
/// version desconocida en lugar de malinterpretarla.
pub fn deserializar(bytes: &[u8]) -> Result<Arbol, &'static str> {
let (arbol, _) =
postcard::take_from_bytes::<Arbol>(bytes).map_err(|_| "arbol :: deserializacion fallida")?;
if arbol.version != VERSION_ARBOL {
return Err("arbol :: version de format desconocida");
}
Ok(arbol)
}
}
/// Construye el objeto BLOB de un archivo: bytes crudos, sin hijos. El hash de
/// este objeto (sobre su forma serializada) es la identidad del archivo en el
/// grafo. Dos archivos con idéntico contenido producen el MISMO blob.
pub fn objeto_blob(datos: Vec<u8>) -> Objeto {
Objeto {
datos,
hijos: Vec::new(),
}
}
/// Construye el objeto ÍNDICE de un archivo GRANDE partido en trozos: `datos`
/// VACÍO, `hijos` = los hashes de los blobs-trozo EN ORDEN. La convención de
/// lectura: una entrada de archivo (`Archivo`/`Ejecutable`) cuyo objeto tiene
/// `hijos` no vacío es un índice, y el contenido del archivo es la
/// concatenación de los `datos` de sus trozos; si `hijos` está vacío, el
/// objeto ES el contenido (blob plano). Así un archivo de cualquier tamaño se
/// referencia igual desde el árbol — el lector decide plano vs índice por la
/// forma del objeto, sin un modo aparte. Un archivo vacío es un blob plano
/// (`datos` vacío, `hijos` vacío), nunca un índice.
pub fn objeto_blob_indice(hijos: Vec<Hash>) -> Objeto {
Objeto {
datos: Vec::new(),
hijos,
}
}
/// Construye el objeto ÁRBOL de un directorio a partir de sus entradas. ORDENA
/// las entradas por nombre (determinismo: mismo directorio → mismo hash) y fija
/// `hijos` con los hashes de las entradas, en el MISMO orden, para que el GC
/// alcance el subárbol siguiendo `hijos` sin parsear el `Arbol`.
pub fn objeto_arbol(mut entradas: Vec<EntradaArbol>) -> Result<Objeto, &'static str> {
entradas.sort_by(|a, b| a.nombre.cmp(&b.nombre));
let hijos: Vec<Hash> = entradas.iter().map(|e| e.hash).collect();
let arbol = Arbol {
version: VERSION_ARBOL,
entradas,
};
let datos = arbol.serializar()?;
Ok(Objeto { datos, hijos })
}
impl SuperBloque {
/// Serializa el superbloque a su forma binaria `postcard`.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "superbloque :: serializacion fallida")
}
/// Reconstruye el superbloque desde el sector 0. Tolera el relleno a cero
/// que completa el sector: solo consume el prefijo serializado.
pub fn deserializar(bytes: &[u8]) -> Result<SuperBloque, &'static str> {
postcard::take_from_bytes::<SuperBloque>(bytes)
.map(|(sb, _)| sb)
.map_err(|_| "superbloque :: deserializacion fallida")
}
}
impl Manifiesto {
/// Serializa el manifiesto a su forma binaria `postcard` — la carga util
/// del objeto del grafo que lo aloja.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "manifiesto :: serializacion fallida")
}
/// Reconstruye un manifiesto desde la carga util de su objeto. Rechaza un
/// format de version desconocida en lugar de malinterpretarlo.
pub fn deserializar(bytes: &[u8]) -> Result<Manifiesto, &'static str> {
let (manifiesto, _) = postcard::take_from_bytes::<Manifiesto>(bytes)
.map_err(|_| "manifiesto :: deserializacion fallida")?;
if manifiesto.version != VERSION_MANIFIESTO {
return Err("manifiesto :: version de format desconocida");
}
Ok(manifiesto)
}
}
impl Canal {
/// Serializa el canal a su forma binaria `postcard` — la carga util del
/// objeto del grafo que lo aloja. Rechaza por adelantado un nombre que
/// supere [`NOMBRE_CANAL_LIMITE`]: mejor un error de serializacion que un
/// canal grafico que no quepa en disco.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
if self.nombre.len() > NOMBRE_CANAL_LIMITE {
return Err("canal :: nombre demasiado largo");
}
postcard::to_allocvec(self).map_err(|_| "canal :: serializacion fallida")
}
/// Reconstruye un canal desde la carga util de su objeto. Rechaza version
/// desconocida y nombres que excedan [`NOMBRE_CANAL_LIMITE`] —un canal con
/// nombre extravagante se detecta al recibirlo, no al servirlo—.
pub fn deserializar(bytes: &[u8]) -> Result<Canal, &'static str> {
let (canal, _) = postcard::take_from_bytes::<Canal>(bytes)
.map_err(|_| "canal :: deserializacion fallida")?;
if canal.version != VERSION_CANAL {
return Err("canal :: version de format desconocida");
}
if canal.nombre.len() > NOMBRE_CANAL_LIMITE {
return Err("canal :: nombre excede el techo");
}
Ok(canal)
}
/// La recomendacion vigente del canal: la ultima `RaizFirmada` por
/// `timestamp`, o `None` si el canal aun no propuso ninguna. Quien quiera
/// "actualizar" sigue este hash; quien quiera rollback elige otra entrada
/// del historial.
pub fn vigente(&self) -> Option<&RaizFirmada> {
self.raices.last()
}
}
/// Compone el mensaje canonico que un autor firma para respaldar una raiz en
/// un canal: la concatenacion `nombre || timestamp_le || raiz_manifiesto`.
/// Es la unica verdad del payload firmable —quien firma y quien verifica han
/// de componerlo por aqui, jamas a mano—. La canonizacion incluye el nombre
/// del canal para que una firma valida en `dev` no se replique en `estable`.
pub fn mensaje_a_firmar(nombre_canal: &str, timestamp: u64, raiz_manifiesto: &Hash) -> Vec<u8> {
let mut mensaje = Vec::with_capacity(nombre_canal.len() + 8 + raiz_manifiesto.len());
mensaje.extend_from_slice(nombre_canal.as_bytes());
mensaje.extend_from_slice(&timestamp.to_le_bytes());
mensaje.extend_from_slice(raiz_manifiesto);
mensaje
}
/// Compone el mensaje canonico que un autor firma para CONCEDER capacidad a un
/// bytecode: `bytecode(32) || permisos_le(4)`. Es la unica verdad del payload
/// firmable de una [`ConcesionCapacidad`] —firmante y verificador lo componen
/// por aqui, jamas a mano—. Liga la firma al hash EXACTO del binario y al
/// bitfield EXACTO: una concesion para el bytecode X no vale para Y, y subir un
/// bit de permiso invalida la firma. Devuelve un arreglo de pila de 36 bytes:
/// zero-alloc, apto para el camino Ring 0 del kernel.
pub fn mensaje_capacidad(bytecode: &Hash, permisos: Permisos) -> [u8; 36] {
let mut m = [0u8; 36];
m[..32].copy_from_slice(bytecode);
m[32..].copy_from_slice(&permisos.to_le_bytes());
m
}
/// Dominio de separacion del mensaje de ROTACION de clave. Un byte canonico de
/// rotacion jamas colisiona con uno de revocacion ni con un claim del grafo.
pub const DOM_ROTACION_CLAVE: &[u8] = b"agora-key-rotation\x01";
/// Dominio de separacion del mensaje de REVOCACION de clave.
pub const DOM_REVOCACION_CLAVE: &[u8] = b"agora-revocation\x01";
/// Compone el mensaje canonico de una ROTACION de clave (handoff voluntario
/// vieja->nueva): `DOM || old(32) || new(32) || issued_at_le(8)`. Tamanos fijos,
/// sin prefijos de largo; el dominio lo separa de otros records. Es la unica
/// verdad del payload firmable de una rotacion — `agora-core::KeyRotation` lo
/// compone por aqui y el kernel lo espeja sobre estos mismos bytes (ver
/// `agora/SDD-rotacion-revocacion.md` §2.1).
pub fn mensaje_rotacion_clave(old_key: &[u8; 32], new_key: &[u8; 32], issued_at: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(DOM_ROTACION_CLAVE.len() + 72);
out.extend_from_slice(DOM_ROTACION_CLAVE);
out.extend_from_slice(old_key);
out.extend_from_slice(new_key);
out.extend_from_slice(&issued_at.to_le_bytes());
out
}
/// Compone el mensaje canonico de una REVOCACION de clave:
/// `DOM || target(32) || [motivo] || issued_at_le(8) || tag || [expires_le(8)]`,
/// donde `tag` es `0` si `expires_at` es `None` y `1` si es `Some` (para que
/// `None` y `Some(0)` no colisionen). El `motivo` es el discriminante estable de
/// `agora-core::RevReason` (0=Compromised, 1=Retired, 2=Superseded) — entra en la
/// firma para que no se pueda "ascender" un retiro a compromiso sin re-firmar.
/// Unica verdad del payload firmable de una revocacion: `agora-core::Revocation`
/// lo compone por aqui y el kernel lo espeja en `claves::verificar_revocacion`
/// (ver `agora/SDD-rotacion-revocacion.md` §2.2 y §4).
pub fn mensaje_revocacion_clave(
target_key: &[u8; 32],
motivo: u8,
issued_at: u64,
expires_at: Option<u64>,
) -> Vec<u8> {
let mut out = Vec::with_capacity(DOM_REVOCACION_CLAVE.len() + 50);
out.extend_from_slice(DOM_REVOCACION_CLAVE);
out.extend_from_slice(target_key);
out.push(motivo);
out.extend_from_slice(&issued_at.to_le_bytes());
match expires_at {
None => out.push(0),
Some(t) => {
out.push(1);
out.extend_from_slice(&t.to_le_bytes());
}
}
out
}
/// Permisos EFECTIVOS de una app: la INTERSECCION de lo que su `EntradaApp` del
/// manifiesto DECLARA y lo que una [`ConcesionCapacidad`] valida CONCEDE para su
/// bytecode. El manifiesto no puede escalar un binario mas alla de su concesion
/// firmada, y una concesion generosa no enciende permisos que el manifiesto no
/// pidio. Sin concesion valida, el llamante pasa `0` como `concedidos` y la app
/// corre sin capacidades gateadas (la matriz pasiva siempre esta). Es la regla
/// que el kernel aplica en el punto de carga —ver `SDD-capacidades.md`—.
pub const fn permisos_efectivos(declarados: Permisos, concedidos: Permisos) -> Permisos {
declarados & concedidos
}
+48
View File
@@ -0,0 +1,48 @@
// =============================================================================
// renaser :: format — el format del grafo de objetos en disco
// -----------------------------------------------------------------------------
// Hasta la Fase 7a, el format del grafo de objetos —el superbloque, los
// registros del log, el manifiesto— vivia disperso entre `kernel/almacen.rs`
// y `kernel/manifiesto.rs`. Lo conocia solo el kernel.
//
// La Fase 7b se lo entrega tambien a `boot`: el constructor de imagen de
// ANFITRION debe sembrar el disco con el grafo ya poblado —los objetos de
// bytecode y el Manifiesto de Genesis— para que el kernel jamas vuelva a
// empotrar una sola app. Para ello, kernel y boot han de hablar EXACTAMENTE
// el mismo format: la misma serializacion, el mismo hash, el mismo trazado
// de registros en el log.
//
// Esta crate es esa unica verdad. Es un nucleo `#![no_std]` —el kernel
// bare-metal la enlaza— y, por ser no_std, el anfitrion `boot` la compila sin
// friccion. Define los tipos del grafo, su (de)serializacion `postcard`, la
// funcion hash BLAKE3 que da identidad a cada objeto y el trazado de un
// registro en el log. Ni kernel ni boot vuelven a definir nada de esto.
// =============================================================================
#![no_std]
extern crate alloc;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use serde_big_array::BigArray;
// --- Split temático del format (todo pub; API plana preservada con `pub use
// <mod>::*`). Cada módulo abre con `use super::*` para ver los otros tipos +
// las imports de alloc/serde del root. Sigue siendo `#![no_std]`. ---
mod cable;
mod constantes;
mod firma;
mod grafo;
mod tipos;
#[cfg(test)]
mod pruebas;
pub use cable::*;
pub use constantes::*;
pub use firma::*;
pub use grafo::*;
pub use tipos::*;
+904
View File
@@ -0,0 +1,904 @@
use super::*;
#[test]
fn blob_no_tiene_hijos() {
let b = objeto_blob(vec![0xAA, 0xBB, 0xCC]);
assert_eq!(b.datos, vec![0xAA, 0xBB, 0xCC]);
assert!(b.hijos.is_empty());
}
#[test]
fn arbol_ordena_entradas_por_nombre() {
// Entradas en orden caótico — el objeto-árbol debe ordenarlas.
let entradas = vec![
EntradaArbol { nombre: "zeta.rs".into(), modo: ModoEntrada::Archivo, hash: [1; 32] },
EntradaArbol { nombre: "alfa.rs".into(), modo: ModoEntrada::Archivo, hash: [2; 32] },
EntradaArbol { nombre: "sub".into(), modo: ModoEntrada::Directorio, hash: [3; 32] },
];
let obj = objeto_arbol(entradas).unwrap();
let arbol = Arbol::deserializar(&obj.datos).unwrap();
let nombres: Vec<&str> = arbol.entradas.iter().map(|e| e.nombre.as_str()).collect();
assert_eq!(nombres, ["alfa.rs", "sub", "zeta.rs"]);
// `hijos` viaja en el MISMO orden que las entradas ordenadas.
assert_eq!(obj.hijos, vec![[2u8; 32], [3u8; 32], [1u8; 32]]);
}
#[test]
fn arbol_es_determinista_independiente_del_orden_de_entrada() {
// El mismo directorio dado en dos órdenes distintos => MISMO hash.
let a = vec![
EntradaArbol { nombre: "b".into(), modo: ModoEntrada::Archivo, hash: [5; 32] },
EntradaArbol { nombre: "a".into(), modo: ModoEntrada::Archivo, hash: [6; 32] },
];
let b = vec![
EntradaArbol { nombre: "a".into(), modo: ModoEntrada::Archivo, hash: [6; 32] },
EntradaArbol { nombre: "b".into(), modo: ModoEntrada::Archivo, hash: [5; 32] },
];
let ha = hash(&objeto_arbol(a).unwrap().serializar().unwrap());
let hb = hash(&objeto_arbol(b).unwrap().serializar().unwrap());
assert_eq!(ha, hb);
}
#[test]
fn arbol_rechaza_version_desconocida() {
let mut arbol = Arbol { version: VERSION_ARBOL, entradas: vec![] };
assert!(Arbol::deserializar(&arbol.serializar().unwrap()).is_ok());
arbol.version = 99;
assert!(Arbol::deserializar(&arbol.serializar().unwrap()).is_err());
}
#[test]
fn indice_de_blob_grande_tiene_datos_vacio_e_hijos() {
let idx = objeto_blob_indice(vec![[1; 32], [2; 32], [3; 32]]);
assert!(idx.datos.is_empty(), "el índice no porta datos, solo hijos");
assert_eq!(idx.hijos, vec![[1u8; 32], [2u8; 32], [3u8; 32]]);
// Distinguible de un archivo vacío (blob plano): hijos no vacío.
let vacio = objeto_blob(vec![]);
assert!(vacio.hijos.is_empty());
}
#[test]
fn modo_es_archivo_distingue_contenido_de_estructura() {
assert!(ModoEntrada::Archivo.es_archivo());
assert!(ModoEntrada::Ejecutable.es_archivo());
assert!(!ModoEntrada::Symlink.es_archivo());
assert!(!ModoEntrada::Directorio.es_archivo());
}
#[test]
fn modos_nuevos_sobreviven_round_trip_en_arbol() {
let entradas = vec![
EntradaArbol { nombre: "run.sh".into(), modo: ModoEntrada::Ejecutable, hash: [1; 32] },
EntradaArbol { nombre: "link".into(), modo: ModoEntrada::Symlink, hash: [2; 32] },
];
let obj = objeto_arbol(entradas).unwrap();
let arbol = Arbol::deserializar(&obj.datos).unwrap();
assert_eq!(arbol.entradas[0].nombre, "link");
assert_eq!(arbol.entradas[0].modo, ModoEntrada::Symlink);
assert_eq!(arbol.entradas[1].modo, ModoEntrada::Ejecutable);
}
#[test]
fn objeto_ida_y_vuelta() {
let objeto = Objeto {
datos: vec![1, 2, 3, 4, 5],
hijos: vec![[7u8; 32], [9u8; 32]],
};
let bytes = objeto.serializar().unwrap();
assert_eq!(Objeto::deserializar(&bytes).unwrap(), objeto);
}
#[test]
fn registro_alineado_a_sector() {
let payload = vec![0xABu8; 600];
let registro = componer_registro(&payload);
// 4 + 600 = 604 bytes => dos sectores de 512.
assert_eq!(registro.len(), 2 * TAM_SECTOR);
assert_eq!(registro.len() % TAM_SECTOR, 0);
assert_eq!(longitud_registro(&registro), Some(600));
assert_eq!(&registro[4..604], &payload[..]);
}
#[test]
fn cuaderno_ida_y_vuelta_con_celdas_mixtas() {
// FASE 43 :: el modelo unificado CeldaWawa empaqueta los cinco
// campos en una sola struct. Roundtrip cubre:
// - celda exitosa con binario y retorno legitimo
// - celda fallida sin binario, sin retorno, con `marca_error`
// - celda fallida con binario pero retorno negativo y error
let celdas: Vec<CeldaWawa> = vec![
CeldaWawa {
id_secuencial: 0,
fuente_hash: [0xA1; 32],
binario_hash: Some([0xB2; 32]),
ultimo_retorno: Some(42),
marca_error: false,
},
CeldaWawa {
id_secuencial: 1,
fuente_hash: [0xC3; 32],
binario_hash: None,
ultimo_retorno: None,
marca_error: true,
},
CeldaWawa {
id_secuencial: 2,
fuente_hash: [0xD4; 32],
binario_hash: Some([0xE5; 32]),
ultimo_retorno: Some(-7),
marca_error: true,
},
];
let bytes = serializar_celdas(&celdas).unwrap();
let leido = deserializar_celdas(&bytes).unwrap();
assert_eq!(leido, celdas);
// Single-cell payload (el caso que produce la PRIMERA anexion
// de `sys_cuaderno_anexar_celda` sobre un cuaderno virgen).
let una: Vec<CeldaWawa> = vec![CeldaWawa {
id_secuencial: 99,
fuente_hash: [0xF0; 32],
binario_hash: None,
ultimo_retorno: Some(0),
marca_error: false,
}];
let bytes = serializar_celdas(&una).unwrap();
let leido = deserializar_celdas(&bytes).unwrap();
assert_eq!(leido, una);
}
#[test]
fn cuaderno_acumulativo_anexa_celdas_en_orden() {
// FASE 47 :: la nueva syscall `sys_cuaderno_anexar_celda` opera
// en el kernel como: recuperar -> deserializar Vec<CeldaWawa> ->
// push(nueva) -> reserializar. Este test reproduce esa cadena
// en miniatura, asegurando que el roundtrip respeta el orden
// cronologico real con id_secuencial creciente.
let mut acumulado: Vec<CeldaWawa> = Vec::new();
for i in 0..5u32 {
// Re-deserializar lo que el kernel "tendria en disco" antes
// del push — refleja exactamente la operacion del host.
let acumulado_actual = if acumulado.is_empty() {
Vec::new()
} else {
let bytes = serializar_celdas(&acumulado).unwrap();
deserializar_celdas(&bytes).unwrap()
};
let mut siguiente = acumulado_actual;
siguiente.push(CeldaWawa {
id_secuencial: i,
fuente_hash: [i as u8; 32],
binario_hash: if i % 2 == 0 {
Some([(i + 0x10) as u8; 32])
} else {
None
},
ultimo_retorno: Some(i as i32),
marca_error: i % 3 == 0,
});
acumulado = siguiente;
}
// Tras 5 anexiones, el cuaderno tiene 5 celdas en orden 0..5.
assert_eq!(acumulado.len(), 5);
for (i, c) in acumulado.iter().enumerate() {
assert_eq!(c.id_secuencial, i as u32);
}
// Roundtrip final del vector acumulado preserva la cadena.
let bytes = serializar_celdas(&acumulado).unwrap();
let leido = deserializar_celdas(&bytes).unwrap();
assert_eq!(leido, acumulado);
}
#[test]
fn cabecera_a_cero_es_fin_del_log() {
assert_eq!(longitud_registro(&[0, 0, 0, 0]), None);
assert_eq!(longitud_registro(&[0xFF, 0xFF, 0xFF, 0xFF]), None);
assert_eq!(longitud_registro(&[3, 0, 0, 0]), Some(3));
}
#[test]
fn manifiesto_rechaza_version_ajena() {
let mut manifiesto = Manifiesto {
version: 99,
apps: Vec::new(),
configuracion: None,
overlay_revocacion: None,
marco: None,
};
let bytes = postcard::to_allocvec(&manifiesto).unwrap();
assert!(Manifiesto::deserializar(&bytes).is_err());
manifiesto.version = VERSION_MANIFIESTO;
assert!(Manifiesto::deserializar(&manifiesto.serializar().unwrap()).is_ok());
}
#[test]
fn manifiesto_transporta_enlace_de_configuracion() {
// Un manifiesto puede nacer sin configuracion (defecto) o cargar el
// hash de un nodo de configuracion en el grafo. Lo que el `serializar`
// escribe es exactamente lo que el `deserializar` recupera.
let con_enlace = Manifiesto {
version: VERSION_MANIFIESTO,
apps: Vec::new(),
configuracion: Some([0xC5; 32]),
overlay_revocacion: None,
marco: None,
};
let bytes = con_enlace.serializar().unwrap();
let leido = Manifiesto::deserializar(&bytes).unwrap();
assert_eq!(leido.configuracion, Some([0xC5; 32]));
let sin_enlace = Manifiesto {
version: VERSION_MANIFIESTO,
apps: Vec::new(),
configuracion: None,
overlay_revocacion: None,
marco: None,
};
let bytes = sin_enlace.serializar().unwrap();
assert!(Manifiesto::deserializar(&bytes)
.unwrap()
.configuracion
.is_none());
}
#[test]
fn configuracion_ida_y_vuelta_y_rechaza_version() {
let cfg = Configuracion {
version: VERSION_CONFIGURACION,
idioma: idioma_iso639(*b"qu"),
paleta: [
0x11, 0x22, 0x33, 0xFF, 0x44, 0x55, 0x66, 0xFF, 0x77, 0x88, 0x99, 0xFF, 0xAA, 0xBB,
0xCC, 0xFF, 0xDD, 0xEE, 0xFF, 0xFF,
],
};
let bytes = cfg.serializar().unwrap();
assert_eq!(Configuracion::deserializar(&bytes).unwrap(), cfg);
// Hashes distintos => identidades distintas. Cambiar la paleta o el
// idioma engendra un nodo nuevo del grafo; ningun cambio se cuela
// bajo el mismo hash.
let mut otro = cfg;
otro.idioma = idioma_iso639(*b"en");
assert_ne!(hash(&otro.serializar().unwrap()), hash(&bytes));
// Version desconocida: se rechaza al deserializar.
let mut ajeno = cfg;
ajeno.version = 99;
let bytes_ajenos = postcard::to_allocvec(&ajeno).unwrap();
assert!(Configuracion::deserializar(&bytes_ajenos).is_err());
}
#[test]
fn configuracion_por_defecto_es_estable() {
// El `por_defecto` debe ser determinista y reconstruirse desde su
// forma binaria sin perder ningun campo. El kernel lo inyecta tal
// cual cuando el manifiesto no enlaza configuracion alguna.
let defecto = Configuracion::por_defecto();
assert_eq!(defecto.version, VERSION_CONFIGURACION);
assert_eq!(defecto.idioma, IDIOMA_DEFECTO);
assert_eq!(defecto.paleta, PALETA_DEFECTO);
let bytes = defecto.serializar().unwrap();
assert_eq!(Configuracion::deserializar(&bytes).unwrap(), defecto);
}
#[test]
fn entrada_app_transporta_permisos_y_distingue_hash() {
// Una entrada con permisos distintos engendra un manifiesto con un
// hash distinto: el bit es CONTENIDO direccionado, no metadato lateral.
// Una app que se "regala" un permiso a si misma no puede pasar por
// la misma app del manifiesto anterior — el grafo lo delata.
let base = EntradaApp {
nombre: String::from("test"),
bytecode: [0x11; 32],
region_x: 0,
region_y: 0,
region_ancho: 100,
region_alto: 100,
techo_memoria: 4 * 1024 * 1024,
fuel_fotograma: 1_000_000,
estado: None,
permisos: 0,
concesion: None,
};
let mut con_red = base.clone();
con_red.permisos = PERMISO_RED;
let manifiesto_a = Manifiesto {
version: VERSION_MANIFIESTO,
apps: vec![base.clone()],
configuracion: None,
overlay_revocacion: None,
marco: None,
};
let manifiesto_b = Manifiesto {
version: VERSION_MANIFIESTO,
apps: vec![con_red],
configuracion: None,
overlay_revocacion: None,
marco: None,
};
assert_ne!(
hash(&manifiesto_a.serializar().unwrap()),
hash(&manifiesto_b.serializar().unwrap()),
"manifiestos con distintos permisos deben dar hashes distintos"
);
// El roundtrip preserva la mascara entera.
let con_todo = EntradaApp {
permisos: PERMISO_RED
| PERMISO_GRAFO_ESCRITURA
| PERMISO_RAIZ
| PERMISO_ALTAVOZ
| PERMISO_CONFIG
| PERMISO_COMPACTAR,
..base.clone()
};
let m = Manifiesto {
version: VERSION_MANIFIESTO,
apps: vec![con_todo],
configuracion: None,
overlay_revocacion: None,
marco: None,
};
let bytes = m.serializar().unwrap();
let leido = Manifiesto::deserializar(&bytes).unwrap();
assert_eq!(leido.apps[0].permisos, 0b111111);
}
#[test]
fn manifiesto_firmado_ida_y_vuelta() {
// Roundtrip serializar->deserializar preserva los tres campos del
// sobre criptografico: hash del manifiesto, llave publica del autor
// y firma. Es el contrato basico de la Fase 25 con el wire/log.
let mf = ManifiestoFirmado {
manifiesto_hash: [0xC5; 32],
autor: [0xA1; 32],
firma: [0x77; 64],
};
let bytes = mf.serializar().unwrap();
let leido = ManifiestoFirmado::deserializar(&bytes).unwrap();
assert_eq!(leido, mf);
// Tamaño acotado: 32 + 32 + 64 = 128 bytes crudos + el preludio
// postcard. Debe caber holgado en un sector y en un frame Ethernet.
assert!(bytes.len() <= 160, "MF demasiado grande: {} bytes", bytes.len());
}
#[test]
fn cuaderno_firmado_ida_y_vuelta() {
// Roundtrip estructural del sobre criptografico del cuaderno
// (Fase 37). Gemelo a `manifiesto_firmado_ida_y_vuelta` — el
// mismo contrato de los tres campos contra el wire/log.
let cf = CuadernoFirmado {
cuaderno_raiz_hash: [0xCE; 32],
autor: [0xA1; 32],
firma: [0x66; 64],
};
let bytes = cf.serializar().unwrap();
let leido = CuadernoFirmado::deserializar(&bytes).unwrap();
assert_eq!(leido, cf);
assert!(
bytes.len() <= 160,
"CuadernoFirmado demasiado grande: {} bytes",
bytes.len()
);
}
#[test]
fn codigo_error_tiene_valores_estables() {
// Anadir una variante NUEVA al enum jamas debe renumerar las
// existentes: el binario WASM viejo compila contra el numero
// literal y kernel + userspace tienen que coincidir aunque el
// catalogo crezca. Este test es el contrato.
assert_eq!(CodigoError::Ok.como_i32(), 0);
assert_eq!(CodigoError::Ausente.como_i32(), -1);
assert_eq!(CodigoError::CapacidadInsuficiente.como_i32(), -2);
assert_eq!(CodigoError::AlmacenamientoFallo.como_i32(), -3);
assert_eq!(CodigoError::SinFoco.como_i32(), -4);
assert_eq!(CodigoError::EnvioFallo.como_i32(), -5);
assert_eq!(CodigoError::Saturado.como_i32(), -6);
assert_eq!(CodigoError::PayloadInvalido.como_i32(), -7);
}
#[test]
fn idioma_iso639_empaqueta_en_little_endian() {
// `es` => 'e' (0x65) en el byte bajo, 's' (0x73) en el alto.
assert_eq!(idioma_iso639(*b"es"), 0x7365);
assert_eq!(idioma_iso639(*b"en"), 0x6E65);
assert_eq!(idioma_iso639(*b"qu"), 0x7571);
}
#[test]
fn canal_ida_y_vuelta_con_dos_raices() {
let canal = Canal {
version: VERSION_CANAL,
nombre: String::from("estable"),
autor: [0xA1; 32],
raices: vec![
RaizFirmada {
timestamp: 1_700_000_000,
raiz_manifiesto: [0x11; 32],
firma: [0x22; 64],
},
RaizFirmada {
timestamp: 1_700_000_100,
raiz_manifiesto: [0x33; 32],
firma: [0x44; 64],
},
],
};
let bytes = canal.serializar().unwrap();
let recuperado = Canal::deserializar(&bytes).unwrap();
assert_eq!(recuperado, canal);
// `vigente` devuelve la ultima entrada por orden, no la mas reciente
// por timestamp — el contrato es que las entradas vienen ordenadas;
// verificarlo es responsabilidad de quien construye el canal.
assert_eq!(recuperado.vigente().unwrap().raiz_manifiesto, [0x33; 32]);
}
#[test]
fn canal_rechaza_version_y_nombre_excedido() {
let mut canal = Canal {
version: 99,
nombre: String::from("dev"),
autor: [0; 32],
raices: Vec::new(),
};
let bytes = postcard::to_allocvec(&canal).unwrap();
assert!(Canal::deserializar(&bytes).is_err());
canal.version = VERSION_CANAL;
assert!(Canal::deserializar(&canal.serializar().unwrap()).is_ok());
// Nombre excedido: el serializador lo veta sin escribir nada al disco.
let largo = Canal {
version: VERSION_CANAL,
nombre: "x".repeat(NOMBRE_CANAL_LIMITE + 1),
autor: [0; 32],
raices: Vec::new(),
};
assert!(largo.serializar().is_err());
}
#[test]
fn mensaje_a_firmar_es_canonico_y_distingue_canales() {
let raiz: Hash = [0x55; 32];
let m1 = mensaje_a_firmar("estable", 42, &raiz);
let m2 = mensaje_a_firmar("estable", 42, &raiz);
assert_eq!(m1, m2, "el mensaje firmable debe ser deterministico");
// Cambiar el canal cambia el mensaje: una firma valida en `dev` no se
// replica en `estable`.
let m3 = mensaje_a_firmar("dev", 42, &raiz);
assert_ne!(m1, m3);
// Cambiar el timestamp tambien — no se replica una recomendacion vieja
// como si fuera nueva.
let m4 = mensaje_a_firmar("estable", 43, &raiz);
assert_ne!(m1, m4);
}
#[test]
fn mensaje_capacidad_es_canonico_y_distingue_bytecode_y_permisos() {
let bc: Hash = [0xAB; 32];
let m1 = mensaje_capacidad(&bc, PERMISO_RED);
assert_eq!(m1, mensaje_capacidad(&bc, PERMISO_RED), "deterministico");
// Layout: bytecode(32) || permisos_le(4).
assert_eq!(&m1[..32], &bc);
assert_eq!(&m1[32..], &PERMISO_RED.to_le_bytes());
// Distinto bytecode => distinto mensaje: una concesion no se transplanta.
let otro: Hash = [0xCD; 32];
assert_ne!(m1, mensaje_capacidad(&otro, PERMISO_RED));
// Distintos permisos => distinto mensaje: subir un bit invalida la firma.
assert_ne!(m1, mensaje_capacidad(&bc, PERMISO_RED | PERMISO_RAIZ));
}
#[test]
fn overlay_revocacion_roundtrip_y_rechaza_version_ajena() {
let overlay = OverlayRevocacion {
version: VERSION_OVERLAY,
revocaciones: vec![RevocacionFirmada {
objetivo: [0x42; 32],
motivo: 0, // Compromised
emitida_en: 1_700_000_000,
vence_en: None,
firmantes: vec![
FirmaRevocacion { autor: [0x10; 32], firma: [0xAA; 64] },
FirmaRevocacion { autor: [0x11; 32], firma: [0xBB; 64] },
],
}],
};
let bytes = overlay.serializar().unwrap();
let leido = OverlayRevocacion::deserializar(&bytes).unwrap();
assert_eq!(leido, overlay);
assert_eq!(leido.revocaciones[0].firmantes.len(), 2);
// Un overlay con versión ajena se rechaza, no se malinterpreta.
let ajeno = OverlayRevocacion { version: 99, revocaciones: Vec::new() };
let bytes = postcard::to_allocvec(&ajeno).unwrap();
assert!(OverlayRevocacion::deserializar(&bytes).is_err());
}
#[test]
fn mensaje_rotacion_clave_layout_y_dominio() {
let vieja = [0x11; 32];
let nueva = [0x22; 32];
let m = mensaje_rotacion_clave(&vieja, &nueva, 0x0A0B0C0D);
// Layout: DOM || old(32) || new(32) || issued_at_le(8).
assert_eq!(&m[..DOM_ROTACION_CLAVE.len()], DOM_ROTACION_CLAVE);
let p = DOM_ROTACION_CLAVE.len();
assert_eq!(&m[p..p + 32], &vieja);
assert_eq!(&m[p + 32..p + 64], &nueva);
assert_eq!(&m[p + 64..], &0x0A0B0C0Du64.to_le_bytes());
// Distinto timestamp => distinto canonico (no se revive una rotacion vieja).
assert_ne!(m, mensaje_rotacion_clave(&vieja, &nueva, 0x0A0B0C0E));
}
#[test]
fn mensaje_revocacion_clave_distingue_motivo_y_no_colisiona_none_some_cero() {
let target = [0x99; 32];
// El motivo entra en el canonico: no se "asciende" un retiro a compromiso.
let comprometida = mensaje_revocacion_clave(&target, 0, 5, None);
let retirada = mensaje_revocacion_clave(&target, 1, 5, None);
assert_ne!(comprometida, retirada);
// Layout permanente: DOM || target(32) || [motivo] || issued_le(8) || 0.
let p = DOM_REVOCACION_CLAVE.len();
assert_eq!(&comprometida[..p], DOM_REVOCACION_CLAVE);
assert_eq!(&comprometida[p..p + 32], &target);
assert_eq!(comprometida[p + 32], 0u8);
assert_eq!(&comprometida[p + 33..p + 41], &5u64.to_le_bytes());
assert_eq!(*comprometida.last().unwrap(), 0u8); // tag None
// `None` y `Some(0)` no colisionan: el tag los separa.
let none = mensaje_revocacion_clave(&target, 1, 5, None);
let some_cero = mensaje_revocacion_clave(&target, 1, 5, Some(0));
assert_ne!(none, some_cero);
assert_eq!(*some_cero.last().unwrap(), 0u8); // ultimo byte de 0u64 LE
assert_eq!(some_cero[p + 41], 1u8); // tag Some
}
#[test]
fn concesion_capacidad_roundtrip() {
let c = ConcesionCapacidad {
bytecode: [0x11; 32],
permisos: PERMISO_RED | PERMISO_RAIZ,
autor: [0x22; 32],
firma: [0x33; 64],
};
let bytes = c.serializar().unwrap();
let vuelta = ConcesionCapacidad::deserializar(&bytes).unwrap();
assert_eq!(c, vuelta);
}
#[test]
fn permisos_efectivos_es_la_interseccion() {
// El manifiesto pide RED|RAIZ pero la concesion solo autoriza RED:
// efectivos = RED. El manifiesto no puede escalar a RAIZ por su cuenta.
let declarados = PERMISO_RED | PERMISO_RAIZ;
let concedidos = PERMISO_RED;
assert_eq!(permisos_efectivos(declarados, concedidos), PERMISO_RED);
// Concesion generosa, manifiesto modesto: efectivos = lo que el
// manifiesto pidio (no enciende lo que no se declaro).
assert_eq!(
permisos_efectivos(PERMISO_RED, PERMISO_RED | PERMISO_ALTAVOZ),
PERMISO_RED
);
// Sin concesion (concedidos=0): cero capacidades gateadas.
assert_eq!(permisos_efectivos(declarados, 0), 0);
}
#[test]
fn superbloque_cabe_en_un_sector_y_vuelve_intacto() {
let sb = SuperBloque {
magia: MAGIA,
version: VERSION_SUPERBLOQUE,
log_inicio: 1,
cursor: 4096,
raiz: Some([1u8; 32]),
manifiesto: Some([2u8; 32]),
};
let bytes = sb.serializar().unwrap();
assert!(bytes.len() <= TAM_SECTOR);
assert_eq!(SuperBloque::deserializar(&bytes).unwrap(), sb);
}
#[test]
fn test_wawa_ecosystem_immutable_vanguard() {
// =====================================================================
// FASE 50 :: VANGUARDIA INMUTABLE DEL ABI WAWA
// ---------------------------------------------------------------------
// Sello de cierre del Manifiesto Tecnico. La firma numerica de las
// ocho variantes licitas de `CodigoError` —el lenguaje compartido
// entre el kernel Ring 0, los modulos WASM Ring 3 y el explorador
// host-side— ha quedado fijada. Este test la consagra:
//
// * Cada variante tiene su valor i32 FIJO en el orden negociado
// a lo largo de las primeras 49 fases. Renumerar una existente
// seria romper, byte a byte, todo binario Ring 3 ya inscrito
// en el grafo direccionado por contenido.
//
// * La conversion `as i32` y la `const fn como_i32` son gemelas:
// ambas extraen el discriminante `#[repr(i32)]` —sin trampa,
// sin tabla auxiliar—.
//
// * El catalogo permanece de cardinalidad ocho: ni una variante
// menos (siempre Ok=0 + siete fallas controladas), ni una mas
// escondida tras renumeracion. Anadir una NUEVA codifica un
// valor entero NUEVO; el contrato no se rompe.
//
// Quien pretenda extender el catalogo en una fase futura debera,
// ANTES de mover una variante, actualizar esta tabla de cierre
// y aceptar que el wire del ecosistema entero ha cambiado de era.
// =====================================================================
// 1. Firma numerica congelada de la vanguardia (Ok + 7 fallas).
const VANGUARDIA: [(CodigoError, i32); 8] = [
(CodigoError::Ok, 0),
(CodigoError::Ausente, -1),
(CodigoError::CapacidadInsuficiente, -2),
(CodigoError::AlmacenamientoFallo, -3),
(CodigoError::SinFoco, -4),
(CodigoError::EnvioFallo, -5),
(CodigoError::Saturado, -6),
(CodigoError::PayloadInvalido, -7),
];
for &(variante, valor) in VANGUARDIA.iter() {
assert_eq!(
variante.como_i32(),
valor,
"ABI roto: {:?} dejo de valer {} — mutacion accidental detectada",
variante,
valor,
);
// `as i32` directo: el `#[repr(i32)]` fija el discriminante en
// ambos caminos —el const fn y el cast— sin tabla auxiliar.
assert_eq!(variante as i32, valor);
}
// 2. La proyeccion debe ser inyectiva: dos variantes distintas no
// pueden compartir su valor i32 — el catalogo de la vanguardia
// no tolera colisiones.
for i in 0..VANGUARDIA.len() {
for j in (i + 1)..VANGUARDIA.len() {
assert_ne!(
VANGUARDIA[i].1, VANGUARDIA[j].1,
"ABI roto: dos variantes comparten valor i32"
);
}
}
// 3. Cardinalidad inmutable: 1 (Ok) + 7 fallas controladas. Cualquier
// fase que pretenda crecer este catalogo debe actualizar el test
// explicitamente; un cambio silencioso se delata aqui.
assert_eq!(
VANGUARDIA.len(),
8,
"ABI roto: cardinalidad del catalogo CodigoError mutada"
);
// 4. Rango cerrado de fallas en [-7, -1]. La cascada de Pluma
// (apps/pluma) y el dispatcher Ring 0 cuentan con este rango
// EXACTO para distinguir codigos de error de retornos legitimos.
let fallas_min = VANGUARDIA.iter().skip(1).map(|&(_, v)| v).min().unwrap();
let fallas_max = VANGUARDIA.iter().skip(1).map(|&(_, v)| v).max().unwrap();
assert_eq!(fallas_min, -7, "ABI roto: el suelo de fallas se desplazo");
assert_eq!(fallas_max, -1, "ABI roto: el techo de fallas se desplazo");
}
#[test]
fn superbloque_porta_log_inicio_distinto_de_uno() {
// Tras una compactacion semantica, `log_inicio` no es 1: apunta al
// sector donde empieza el segmento limpio recien escrito. El
// superbloque sigue cabiendo en su sector y el roundtrip preserva
// el campo: el GC depende de esa simetria.
let sb = SuperBloque {
magia: MAGIA,
version: VERSION_SUPERBLOQUE,
log_inicio: 32_768,
cursor: 33_500,
raiz: Some([0xAA; 32]),
manifiesto: Some([0xBB; 32]),
};
let bytes = sb.serializar().unwrap();
assert!(bytes.len() <= TAM_SECTOR);
let leido = SuperBloque::deserializar(&bytes).unwrap();
assert_eq!(leido.log_inicio, 32_768);
assert_eq!(leido.cursor, 33_500);
}
// === Fase 60: MensajeAsistente ===
#[test]
fn mensaje_asistente_consulta_ida_y_vuelta() {
let msg = MensajeAsistente::Consulta {
id: 0xDEADBEEF,
prompt: "lanza pluma".into(),
contexto: Contexto {
apps: vec!["pluma".into(), "bitacora".into()],
manifiesto_actual: Some([0x11; 32]),
configuracion_activa: None,
},
};
let bytes = msg.serializar().unwrap();
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
assert_eq!(leido, msg);
}
#[test]
fn mensaje_asistente_propuesta_lanzar_app() {
let msg = MensajeAsistente::Propuesta {
id: 42,
accion: AccionPropuesta::LanzarApp { plantilla: 7 },
explicacion: "abre pluma para tomar notas".into(),
confianza: 0.95,
};
let bytes = msg.serializar().unwrap();
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
assert_eq!(leido, msg);
}
#[test]
fn mensaje_asistente_propuesta_instalar_app() {
let msg = MensajeAsistente::Propuesta {
id: 100,
accion: AccionPropuesta::InstalarApp {
manifiesto_propuesto: [0xAB; 32],
},
explicacion: "manifiesto v2 firmado".into(),
confianza: 1.0,
};
let bytes = msg.serializar().unwrap();
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
assert_eq!(leido, msg);
}
#[test]
fn mensaje_asistente_error_ida_y_vuelta() {
let msg = MensajeAsistente::Error {
id: 0,
motivo: "LLM rate-limited".into(),
};
let bytes = msg.serializar().unwrap();
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
assert_eq!(leido, msg);
}
#[test]
fn mensaje_asistente_basura_rechazada() {
// Bytes arbitrarios — postcard debe rechazar sin panic.
let basura = [0xFFu8; 16];
assert!(MensajeAsistente::deserializar(&basura).is_err());
}
#[test]
fn mensaje_asistente_propuesta_notar_sin_efecto() {
// `Notar` permite respuestas informativas: el LLM contesta una
// pregunta sin proponer una accion ejecutable.
let msg = MensajeAsistente::Propuesta {
id: 1,
accion: AccionPropuesta::Notar {
texto: "tienes 3 apps abiertas en el escritorio 1".into(),
},
explicacion: String::new(),
confianza: 1.0,
};
let bytes = msg.serializar().unwrap();
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
assert_eq!(leido, msg);
}
#[test]
fn canal_asistente_no_choca_con_otros() {
// 0x4153 = "AS". Si más adelante se registran otros canales
// (chasqui, agora, etc.) este test recuerda el namespace
// ocupado. Cambiar el valor requiere actualizar el doc.
assert_eq!(CANAL_ASISTENTE, 0x4153);
assert_eq!(&CANAL_ASISTENTE.to_be_bytes(), b"AS");
}
#[test]
fn ethertype_asistente_distinto_de_akasha() {
// El demuxer Akasha del kernel descarta payloads que no parsean
// como `MensajeAkasha`. Si usaramos 0x88B5, los frames del
// asistente caerian como `PayloadInvalido` y se contarian en
// `RX_DESCARTADOS` antes de pasar al usuario. Con 0x88B6 caen
// en la rama `EtherTypeAjeno` que va directo al usuario.
assert_eq!(ETHERTYPE_ASISTENTE, 0x88B6);
assert_ne!(ETHERTYPE_ASISTENTE, 0x88B5);
}
#[test]
fn cabecera_cable_round_trip_consulta() {
let mut buf = [0u8; 32];
let n = escribir_cabecera_cable(&mut buf, TipoCable::Consulta, 0xDEADBEEFCAFEBABE)
.expect("cabe");
assert_eq!(n, TAM_CABECERA_CABLE);
let (tipo, id) = leer_cabecera_cable(&buf).expect("valida");
assert_eq!(tipo, TipoCable::Consulta);
assert_eq!(id, 0xDEADBEEFCAFEBABE);
}
#[test]
fn cabecera_cable_round_trip_propuesta_lanzar() {
let mut buf = [0u8; 12];
escribir_cabecera_cable(&mut buf, TipoCable::PropuestaLanzarApp, 7).unwrap();
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
assert_eq!(tipo, TipoCable::PropuestaLanzarApp);
assert_eq!(id, 7);
}
#[test]
fn cabecera_cable_rechaza_canal_ajeno() {
let mut buf = [0u8; 12];
// Forjamos una cabecera con canal distinto al asistente.
buf[0..2].copy_from_slice(&0xABCDu16.to_be_bytes());
buf[2..4].copy_from_slice(&(TipoCable::Consulta as u16).to_be_bytes());
assert!(leer_cabecera_cable(&buf).is_none());
}
#[test]
fn cabecera_cable_rechaza_tipo_desconocido() {
let mut buf = [0u8; 12];
buf[0..2].copy_from_slice(&CANAL_ASISTENTE.to_be_bytes());
buf[2..4].copy_from_slice(&999u16.to_be_bytes()); // tipo inválido
assert!(leer_cabecera_cable(&buf).is_none());
}
#[test]
fn cabecera_cable_rechaza_truncada() {
let buf = [0u8; 5];
assert!(leer_cabecera_cable(&buf).is_none());
}
#[test]
fn escribir_cabecera_cable_rechaza_buffer_corto() {
let mut buf = [0u8; 5];
assert!(escribir_cabecera_cable(&mut buf, TipoCable::Consulta, 0).is_none());
}
#[test]
fn tipo_cable_codigos_estables() {
// Si alguien renumera los discriminantes, los lectores
// binarios viejos rompen. Este test caza el cambio.
assert_eq!(TipoCable::Consulta as u16, 1);
assert_eq!(TipoCable::PropuestaNotar as u16, 2);
assert_eq!(TipoCable::PropuestaLanzarApp as u16, 3);
assert_eq!(TipoCable::PropuestaInstalarApp as u16, 4);
assert_eq!(TipoCable::PropuestaCambiarConfig as u16, 5);
assert_eq!(TipoCable::Error as u16, 6);
assert_eq!(TipoCable::RequestFirma as u16, 7);
assert_eq!(TipoCable::Firma as u16, 8);
}
#[test]
fn cabecera_cable_round_trip_request_firma() {
// Fase 60 v4 :: la app pide firma humana. Round-trip por la
// misma puerta — el `id` corresponde al de la propuesta original.
let mut buf = [0u8; 12];
escribir_cabecera_cable(&mut buf, TipoCable::RequestFirma, 99).unwrap();
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
assert_eq!(tipo, TipoCable::RequestFirma);
assert_eq!(id, 99);
}
#[test]
fn cabecera_cable_round_trip_firma() {
let mut buf = [0u8; 12];
escribir_cabecera_cable(&mut buf, TipoCable::Firma, 99).unwrap();
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
assert_eq!(tipo, TipoCable::Firma);
assert_eq!(id, 99);
}
#[test]
fn tipo_objeto_codigos_estables() {
// El primer byte del payload de RequestFirma. La app wasm y
// el puente leen estos numeros literalmente — renumerarlos
// rompe el cable.
assert_eq!(TIPO_OBJETO_CUADERNO, 1);
assert_eq!(TIPO_OBJETO_CONFIGURACION, 2);
}
#[test]
fn tipo_cable_de_u16_acepta_nuevos() {
assert_eq!(TipoCable::de_u16(7), Some(TipoCable::RequestFirma));
assert_eq!(TipoCable::de_u16(8), Some(TipoCable::Firma));
assert_eq!(TipoCable::de_u16(9), None);
}
+334
View File
@@ -0,0 +1,334 @@
use super::*;
// =============================================================================
// Los tipos del grafo
// =============================================================================
/// Un objeto del grafo: una carga util opaca y las aristas que lo enlazan con
/// otros objetos. Los `hijos` hacen del almacen un DAG —no un arbol—: un
/// objeto puede ser hijo de muchos, y el direccionamiento por contenido
/// garantiza que cada contenido distinto se guarda una sola vez.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Objeto {
/// La carga util del objeto: bytes crudos, que nadie interpreta aqui.
pub datos: Vec<u8>,
/// Los hashes de los objetos hijos: las aristas salientes del DAG.
pub hijos: Vec<Hash>,
}
/// El superbloque: el sector 0 del disco. Ancla el grafo entero — dice donde
/// arranca el log activo, donde acaba, cual es el objeto raiz y cual el
/// manifiesto.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct SuperBloque {
/// Firma magica: debe ser [`MAGIA`].
pub magia: [u8; 8],
/// Version del format: debe ser [`VERSION_SUPERBLOQUE`].
pub version: u32,
/// Primer sector del log activo. En un disco virgen es `1` (justo despues
/// del superbloque); el compactador semantico (Fase 24) lo desplaza al
/// principio de un segmento limpio cada vez que aspira los nodos muertos.
/// Mover `log_inicio` (junto con `cursor`) en una sola escritura del
/// superbloque es lo que convierte la compactacion en una transicion
/// atomica: el log viejo queda en sectores anteriores, ya inalcanzables,
/// pero el grafo logico es el mismo.
pub log_inicio: u64,
/// Proximo sector libre del log — donde se anexara el siguiente objeto.
pub cursor: u64,
/// El objeto raiz del DAG: el punto de entrada que el userspace fija y lee.
pub raiz: Option<Hash>,
/// El Manifiesto de Genesis: el objeto que dicta que apps nacen del grafo
/// al arrancar. Ancla del kernel, gemela de `raiz` (del userspace).
pub manifiesto: Option<Hash>,
}
/// El Manifiesto de Genesis: la lista de aplicaciones que el kernel instancia
/// al arrancar. Vive como un objeto del grafo; el superbloque guarda su hash.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Manifiesto {
/// Version del format — debe ser [`VERSION_MANIFIESTO`].
pub version: u32,
/// Las aplicaciones del userspace, en orden de arranque.
pub apps: Vec<EntradaApp>,
/// Hash del nodo `Configuracion` activo (idioma + tema). `None` => el
/// kernel emplea los valores por defecto. Cambiar de idioma o tema NO
/// muta este nodo: engendra una `Configuracion` nueva, calcula su hash,
/// y reancla el manifiesto al objeto nuevo en un solo paso atomico —el
/// mismo trazado que `EntradaApp::estado` para el estado por app.
pub configuracion: Option<Hash>,
/// Hash del nodo [`OverlayRevocacion`] vigente, o `None` si el operador no
/// ancló ninguno (el caso común — sin revocaciones de claves del anillo).
/// El kernel lo lee FRESH en el arranque y deniega en `autor_en_anillo` toda
/// clave del anillo revocada M-of-N: así una clave soberana filtrada se
/// apaga ENTRE reflasheos, sin esperar al re-forjado del binario. Es la pieza
/// del plano de CONTROL del SDD-rotacion-revocacion §4 — gemela de
/// `configuracion`/`estado`: reanclar engendra un overlay nuevo y mueve el
/// puntero del manifiesto, jamás muta en sitio.
pub overlay_revocacion: Option<Hash>,
/// Hash del nodo del **marco del escritorio** (`pata`) activo: un
/// `pata_core::wire::WireConfig` serializado con postcard. `None` => el
/// kernel emplea el marco por defecto. Gemelo de `configuracion`/
/// `overlay_revocacion`: proponer un marco nuevo (capacidad WASM
/// `sys_marco_proponer`) engendra un nodo nuevo, calcula su hash y reancla el
/// manifiesto al objeto nuevo —jamás muta en sitio—, así el marco sobrevive
/// al reinicio. `format` sólo transporta el `Hash`; el (de)serializado del
/// `WireConfig` lo hace `pata-core`, no este crate.
pub marco: Option<Hash>,
}
/// Un idioma codificado como un par de letras ASCII ISO 639-1 empaquetado en
/// little-endian: `b'e' | (b's' << 8) == 0x7365` para castellano, `0x6E65`
/// para ingles, `0x7571` para quechua. El propio numero es trivialmente
/// legible al inspeccionarlo en hexadecimal —no hace falta una tabla—.
pub type IdiomaCodigo = u16;
/// Compone un `IdiomaCodigo` desde un par ISO 639-1 (`b"es"`, `b"qu"`...).
/// Las dos letras viajan en orden de lectura: la primera ocupa el byte bajo.
pub const fn idioma_iso639(letras: [u8; 2]) -> IdiomaCodigo {
(letras[0] as u16) | ((letras[1] as u16) << 8)
}
/// Codigo de idioma por defecto: `es` (castellano). Lo emplea el kernel cuando
/// el manifiesto no enlaza ninguna `Configuracion`.
pub const IDIOMA_DEFECTO: IdiomaCodigo = idioma_iso639(*b"es");
/// La paleta de un tema visual: cinco colores RGBA8 — primario, secundario,
/// fondo, texto, acento— en ese orden. La forma binaria (20 bytes) es la
/// misma que la app recibe del kernel a traves de la capacidad pasiva
/// `sys_config_paleta`. Cinco colores cubren un esquema completo sin caer en
/// la trampa de "un color por widget": la consistencia visual la impone el
/// numero pequeño.
pub type Paleta = [u8; 20];
/// Paleta por defecto cuando el manifiesto no enlaza configuracion. Negro de
/// fondo, blanco de texto, azul renaser de acento; cualquier app pinta sin
/// adivinar. Cada cuatro bytes son R, G, B, A en ese orden.
pub const PALETA_DEFECTO: Paleta = [
0x20, 0x80, 0xC0, 0xFF, // primario — azul renaser
0x60, 0x60, 0x60, 0xFF, // secundario — gris medio
0x00, 0x00, 0x00, 0xFF, // fondo — negro
0xFF, 0xFF, 0xFF, 0xFF, // texto — blanco
0xF0, 0x90, 0x20, 0xFF, // acento — ambar
];
/// La configuracion activa de Wawa: idioma + paleta del tema. Es un objeto
/// del grafo —direccionado por su hash—; el manifiesto la enlaza. Cambiar de
/// idioma o tema significa engendrar UN NODO NUEVO y reanclar el manifiesto
/// al hash del nuevo objeto en una sola transicion atomica. Sin estados
/// mutables globales: la "configuracion vigente" es siempre el hash al que
/// apunta el manifiesto en este preciso fotograma.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Configuracion {
/// Version del format — debe ser [`VERSION_CONFIGURACION`].
pub version: u32,
/// Idioma activo (ISO 639-1 empaquetado, ver [`idioma_iso639`]).
pub idioma: IdiomaCodigo,
/// Paleta del tema visual: cinco colores RGBA8 en orden canonico.
pub paleta: Paleta,
}
impl Configuracion {
/// La configuracion canonica cuando el manifiesto no enlaza ninguna:
/// idioma `es`, paleta `PALETA_DEFECTO`. El kernel la inyecta tal cual en
/// el `ContextoCapacidades` de cada app.
pub const fn por_defecto() -> Configuracion {
Configuracion {
version: VERSION_CONFIGURACION,
idioma: IDIOMA_DEFECTO,
paleta: PALETA_DEFECTO,
}
}
/// Serializa la configuracion a su forma binaria `postcard`.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "configuracion :: serializacion fallida")
}
/// Reconstruye una configuracion desde la carga util de su objeto. Rechaza
/// una version desconocida en lugar de malinterpretarla — gemelo del trato
/// que `Manifiesto::deserializar` da a su propia version.
pub fn deserializar(bytes: &[u8]) -> Result<Configuracion, &'static str> {
let (cfg, _) = postcard::take_from_bytes::<Configuracion>(bytes)
.map_err(|_| "configuracion :: deserializacion fallida")?;
if cfg.version != VERSION_CONFIGURACION {
return Err("configuracion :: version de format desconocida");
}
Ok(cfg)
}
}
/// Bitfield de permisos de una app — cada bit habilita una clase de
/// capacidades. Capacidades sensibles que no figuran aqui no se ENLAZAN en
/// el `Linker` de wasmi cuando la app se instancia: el import del modulo
/// queda sin resolver y el modulo entero ni siquiera arranca. La frontera
/// es fisica; el kernel no hace chequeos en cada syscall porque no hay
/// syscall que chequear: la funcion del host no se concedio. POSIX gestiona
/// privilegios con un check `if (uid == 0)` en cada syscall y se llena de
/// CVE; aqui no hay nada que comprobar.
pub type Permisos = u32;
/// Permite enviar y recibir frames Ethernet y solicitar objetos por hash a
/// peers Akasha. Sin este bit, las capacidades `sys_net_*` y `sys_red_*` no
/// se enlazan: el modulo no las puede invocar porque no existen.
pub const PERMISO_RED: Permisos = 1 << 0;
/// Permite grabar objetos nuevos en el grafo del disco (`sys_object_put`).
/// La lectura del grafo es libre —la inmutabilidad direccionada por contenido
/// la hace inofensiva—, la escritura no.
pub const PERMISO_GRAFO_ESCRITURA: Permisos = 1 << 1;
/// Permite reanclar la raiz del grafo (`sys_object_fijar_raiz`). Cambia el
/// punto de entrada que el resto del userspace lee; un permiso de mucha
/// gravedad.
pub const PERMISO_RAIZ: Permisos = 1 << 2;
/// Permite hacer sonar la bocina del PC (`sys_tono`). El altavoz es un
/// recurso unico y global; aunque ya esta gateado por foco, el bit deja
/// explicito que la app puede SOLICITAR sonido.
pub const PERMISO_ALTAVOZ: Permisos = 1 << 3;
/// Permite proponer una nueva `Configuracion` (`sys_config_proponer`):
/// idioma + tema visual. La LECTURA pasiva del contexto (sys_config_idioma,
/// sys_config_paleta) no necesita bit; cualquier app la tiene siempre.
pub const PERMISO_CONFIG: Permisos = 1 << 4;
/// Permite forzar una pasada del compactador semantico del grafo
/// (`sys_grafo_compactar`). El GC ya corre solo cuando
/// `escrituras_pendientes() >= UMBRAL_GC` en el tic ocioso del compositor;
/// este bit habilita la palanca explicita para `wawactl gc` y similares.
/// Por su coste (toma el cerrojo del almacen y reescribe sectores), se
/// asume reservado a apps de mantenimiento privilegiadas — no apto para
/// userspace generico.
pub const PERMISO_COMPACTAR: Permisos = 1 << 5;
/// Permite llamar al motor `tinkuy` embebido en el kernel: una sub-jaula
/// `wasmi` aparte, con su propio Store y su propio fuel — la que carga
/// `assets/tinkuy.wasm` y expone los `tk_*`. La capa de capacidades
/// `sys_tinkuy_*` enlaza solo si el bit esta puesto. El motor tinkuy es
/// computo puro (sin red, sin grafo, sin altavoz): el bit lo SE PARA del
/// resto de capacidades, no porque sea privilegiado, sino porque tiene
/// memoria persistente entre `tick`s — una app que lo tenga puede
/// secuestrar slots de simulacion entre fotogramas y conviene que el
/// operador lo declare a sabiendas.
pub const PERMISO_TINKUY: Permisos = 1 << 6;
/// Una entrada del manifiesto: una aplicacion del userspace y todo lo que el
/// kernel necesita para darle vida — su bytecode, su ventana, su cuota de
/// memoria, su tabla de permisos y, si lo tuviera, su ultimo estado persistido.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct EntradaApp {
/// Nombre legible — para los rotulos de la consola y la baliza.
pub nombre: String,
/// Hash del objeto del grafo que contiene el bytecode WASM de la app.
pub bytecode: Hash,
/// Sub-region del framebuffer asignada a la app. Campos de ancho fijo
/// `u32` A PROPOSITO: esto es un format EN DISCO. La `RegionPantalla` del
/// kernel usa `usize` (ancho dependiente de plataforma) y no serializa.
pub region_x: u32,
pub region_y: u32,
pub region_ancho: u32,
pub region_alto: u32,
/// Techo de memoria lineal de la app, en bytes. Cada app lleva su cuota.
pub techo_memoria: u32,
/// Presupuesto de combustible (unidades de wasmi) que la app recibe en
/// cada `tick`. Es el techo TEMPORAL por fotograma: lo agota una app en
/// bucle infinito (`SinCombustible`) y se desaloja. Por-app porque un
/// editor con tree-sitter no necesita lo mismo que un reloj parpadeante;
/// el scheduler cooperativo honra la declaracion en lugar de un techo unico.
pub fuel_fotograma: u32,
/// Hash del ultimo estado persistido de la app (Fase 7c). `None` hasta que
/// la app guarde estado por primera vez.
pub estado: Option<Hash>,
/// Bitfield de permisos (ver [`Permisos`] y las constantes `PERMISO_*`).
/// Lo evalua el `Linker` de wasmi al instanciar la app: las capacidades
/// gateadas que no figuren aqui NO se registran. La app puede llamar a
/// otras capacidades —la matriz pasiva siempre esta— pero las gateadas
/// son, literalmente, simbolos inexistentes para el modulo.
///
/// Estos son los permisos DECLARADOS. Los EFECTIVOS (lo que el kernel
/// enlaza de verdad) salen de [`permisos_efectivos`]`(permisos, concedidos)`
/// donde `concedidos` viene de la [`ConcesionCapacidad`] referida por
/// [`concesion`](Self::concesion). El manifiesto puede pedir menos, nunca mas.
pub permisos: Permisos,
/// Fase 67 / WAWA §14.1.3 — hash de la [`ConcesionCapacidad`] que firma el
/// par `(bytecode, permisos)` de esta app, o `None`. La concesion vive como
/// un objeto del grafo (direccionado por contenido); el kernel la recupera,
/// verifica su firma contra el `AGORA_AUTH_RING` y toma la interseccion de
/// sus permisos con los declarados aqui. `None` ⇒ sin techo per-bytecode:
/// el kernel honra `permisos` tal cual (la integridad la da la firma del
/// manifiesto). El binding "que binario puede que" queda asi INDEPENDIENTE
/// del manifiesto: re-firmar un manifiesto no escala un binario por encima
/// de su concesion.
pub concesion: Option<Hash>,
}
/// Un canal de release: un objeto del grafo que historiza, en orden cronologico,
/// las raices de manifiesto que su(s) autor(es) recomiendan. Es el equivalente
/// nativo de un repositorio apt/dnf/pacman, pero firmado por una `AgoraId` y no
/// por una infraestructura central. Quien se suscribe a un canal confia en su
/// autor; el canal nunca dice "esta es la unica version", dice "esta es mi
/// recomendacion en este momento". El historial completo viaja junto.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Canal {
/// Version del format — debe ser [`VERSION_CANAL`].
pub version: u32,
/// Nombre legible del canal: `estable`, `beta`, `dev`, `cofradia-tal`.
/// Acotado a [`NOMBRE_CANAL_LIMITE`] bytes para que la cabecera sea barata.
pub nombre: String,
/// La identidad del autor que firma este canal. Quien recibe el canal
/// verifica que cada `RaizFirmada` lleve una firma valida sobre esta clave.
/// Un canal puede cambiar de autor en una version futura (multi-firma); por
/// ahora, una clave gobierna un canal.
pub autor: AgoraId,
/// El historial de raices recomendadas, ordenado por `timestamp` ascendente.
/// La ultima entrada es la recomendacion vigente. El historial completo se
/// conserva para que un nodo pueda volver atras —rollback— sin pedirle
/// permiso al canal.
pub raices: Vec<RaizFirmada>,
}
/// El sobre criptografico de un Manifiesto: empareja su hash BLAKE3 con la
/// firma Ed25519 que un autor `AgoraId` produjo sobre el. Fase 25 — el
/// kernel solo acepta una propuesta de reancla del manifiesto cuando llega
/// envuelta en uno de estos sobres Y la firma valida contra la clave publica
/// del usuario local que el binario del kernel lleva grabada. Sin firma
/// valida, no hay mutacion: la mudanza de raiz es un PACTO MATEMATICO
/// explicito, no una orden ciega de la red.
///
/// El mensaje que se firma es, literalmente, los 32 bytes de
/// `manifiesto_hash` — el hash mismo es ya el resumen criptografico del
/// payload del manifiesto, asi que firmar el hash equivale a firmar el
/// manifiesto completo. Ed25519 no se preocupa por la longitud del mensaje.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct ManifiestoFirmado {
/// Hash BLAKE3 del Manifiesto propuesto. Un hash idiosincratico = un
/// manifiesto idiosincratico — ningun atacante puede sustituirlo y
/// reutilizar la firma sin reproducir el hash exacto.
pub manifiesto_hash: Hash,
/// Llave publica Ed25519 del autor que firma esta propuesta. El kernel
/// la compara contra su clave local empotrada antes de molestarse en
/// verificar la firma: una llave ajena cae con `CapacidadInsuficiente`
/// sin gastar ciclos en criptografia.
pub autor: AgoraId,
/// Firma Ed25519 sobre los 32 bytes de `manifiesto_hash`.
#[serde(with = "BigArray")]
pub firma: Firma,
}
impl ManifiestoFirmado {
/// Serializa el sobre a su forma binaria `postcard` — la carga util del
/// objeto del grafo que lo aloja (o el payload de un mensaje Akasha).
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self)
.map_err(|_| "manifiesto_firmado :: serializacion fallida")
}
/// Reconstruye un sobre desde su forma binaria. Tolera bytes sobrantes
/// tras la estructura — el relleno del registro.
pub fn deserializar(bytes: &[u8]) -> Result<ManifiestoFirmado, &'static str> {
postcard::take_from_bytes::<ManifiestoFirmado>(bytes)
.map(|(mf, _)| mf)
.map_err(|_| "manifiesto_firmado :: deserializacion fallida")
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "rimay-localize"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "rimay-localize — i18n del escritorio gioser sobre fluent. Catálogos .ftl embebidos por idioma, API global t!(id) con args opcionales, detección de locale del sistema con fallback a es-PE. Los *-core no contienen strings de UI: emiten IDs (MsgId) que las apps Llimphi resuelven aquí."
[dependencies]
fluent-bundle = { workspace = true }
unic-langid = { workspace = true }
sys-locale = { workspace = true }
once_cell = { workspace = true }
parking_lot = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
+34
View File
@@ -0,0 +1,34 @@
# rimay-localize — localización (i18n) declarativa
Catálogos de cadenas por idioma + una API de lookup minimalista para las apps de
gioser. Los catálogos son **datos** (JSON) cargables en tiempo de ejecución; el
código pide una clave y un idioma y obtiene la cadena, con interpolación de
placeholders `{nombre}`.
## Qué expone
- `Lang` — etiqueta de idioma (BCP-47 simplificado).
- `Catalog` — mapa clave → cadena para un idioma.
- `Localizer` — colección de catálogos + idioma activo + fallback.
- Lookup con sustitución de placeholders por argumentos con nombre.
## Nota de naming
`rimay` es el dominio de lenguaje/voz; este crate es su utilidad de localización
transversal (no es parte del núcleo de `rimay`).
## Estado (2026-05-31)
### Hecho
- Carga de catálogos JSON por idioma.
- Lookup con idioma activo + fallback y tests.
- Interpolación de placeholders `{nombre}`.
### Pendiente
- Pluralización compleja (ICU) y reglas por idioma.
- Detección del idioma del sistema (hoy lo decide la app).
- Herramienta de extracción/validación de claves faltantes.
## Lugar en el repo
`shared/rimay-localize` — utilidad i18n transversal para apps gioser.
@@ -0,0 +1,37 @@
//! `showcase` — imprime todos los IDs del catálogo en los 3 idiomas
//! soportados. Smoke test visual + referencia para revisores.
//!
//! Ejecutar con: `cargo run -p rimay-localize --example showcase`
use std::borrow::Cow;
use rimay_localize as l10n;
/// IDs en el orden de aparición en los `.ftl`. Mantener sincronizado a
/// mano — el ejemplo es referencia, no test exhaustivo.
const IDS: &[&str] = &[
"save", "load", "open", "close", "cancel", "confirm", "yes", "no", "delete", "edit", "new",
"play", "pause", "resume", "stop", "file", "view", "help", "settings", "exit", "info",
"warning", "error", "success",
];
fn main() {
let locales = l10n::available_locales();
for locale in &locales {
l10n::set_locale(locale).unwrap();
println!("\n========= {locale} =========");
for id in IDS {
println!(" {:<10} {}", id, l10n::t(id));
}
println!(
" {:<10} {}",
"welcome-user",
l10n::t_args("welcome-user", &[("name", Cow::Borrowed("Sergio"))])
);
println!(
" {:<10} {}",
"items-count",
l10n::t_args("items-count", &[("count", Cow::Borrowed("3"))])
);
}
}
+720
View File
@@ -0,0 +1,720 @@
# rimay-localize — en-US catalog
# === generic actions ===
save = Save
load = Load
open = Open
close = Close
cancel = Cancel
confirm = OK
yes = Yes
no = No
delete = Delete
edit = Edit
new = New
# === state ===
play = Play
pause = Pause
resume = Resume
stop = Stop
# === menus ===
file = File
view = View
help = Help
settings = Settings
exit = Exit
# === common chrome (reusable across all Llimphi apps) ===
# Shared menu/action labels. An app only mints its own (`<app>-*`) IDs
# for text not covered here.
search = Search
language = Language
undo = Undo
redo = Redo
cut = Cut
copy = Copy
paste = Paste
select-all = Select All
open-dots = Open…
save-as = Save As…
close-tab = Close Tab
find-in-file = Find in File
find-in-project = Find in Project
symbols = Symbols
goto-definition = Go to Definition
terminal = Terminal
command-palette = Command Palette
minimap = Minimap
cycle-theme = Cycle Theme
editing = Editing
about = About
refresh = Refresh
reconnect = Reconnect
# === nada (file editor) ===
nada-tagline = a sovereign editor on Llimphi
# === message levels ===
info = Info
warning = Warning
error = Error
success = Done
# === interpolation ===
welcome-user = Welcome, { $name }.
items-count = { $count } items.
# === dominium (mean-field simulator) ===
dominium-status-running = ● running
dominium-status-paused = ‖ paused
dominium-status-line = dominium · mean field · epoch { $epoch } · tick { $tick }
dominium-btn-pause = ‖ Pause
dominium-btn-resume = ▶ Resume
dominium-btn-reseed = ↺ Reseed
dominium-btn-create-concept = ✦ Create concept
dominium-btn-seed-pack = ✚ Seed pack
dominium-btn-clear = ✖ Clear
dominium-btn-save = 💾 Save
dominium-btn-load-saved = 📂 Load saved
dominium-btn-load-named = ✓ Load «{ $name }»
dominium-header-sim = [ SIM ]
dominium-header-conceptos = [ CONCEPTS ]
dominium-header-metricas = [ METRICS ]
dominium-header-editar = [ EDIT ]
dominium-active-count = { $count } active
dominium-stat-population = Population
dominium-stat-materia = Matter
dominium-stat-oro = Gold
dominium-stat-energia = Energy
dominium-stat-epoca = Epoch
dominium-stat-gini-energia = Gini energy
dominium-stat-edad-media = Mean age
dominium-stat-var-psi-orden = Var ψ order
dominium-stat-var-psi-miedo = Var ψ fear
dominium-stat-var-psi-curiosidad = Var ψ curiosity
dominium-stat-var-psi-corruptib = Var ψ corrupt.
dominium-action-mover = → move
dominium-action-extraer = → extract
dominium-action-sincronizar = → sync
dominium-action-intercambiar = → swap
dominium-action-replicar = → replicate
dominium-action-degradar = → degrade
dominium-slider-nombre = name
dominium-slider-radius = radius
dominium-slider-materia = matter
dominium-slider-psique = psyche
dominium-slider-poder = power
dominium-slider-oro = gold
dominium-label-hack = hack:
# === cosmos (overlay modules) ===
cosmos-btn-save-transit = 💾 Save transit as free chart
cosmos-btn-save-progressed = 💾 Save progressed as free chart
cosmos-btn-save-return = 💾 Save return as free chart
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
cosmos-demo-title = Sample chart (Lima)
cosmos-demo-subtitle = computed by cosmos-engine (VSOP2013)
cosmos-status = { $ms } ms · { $layers } layers · { $overlays } overlays · { $aspects } aspects
cosmos-status-error = error: { $err }
cosmos-overlay-transit = transit
cosmos-overlay-progression = progression
cosmos-overlay-solar-arc = solar arc
cosmos-overlay-uranian = uranian
cosmos-overlay-lots = lots
cosmos-overlay-fixed-stars = fixed stars
cosmos-overlay-midpoints = midpoints
cosmos-harmonic-label = harmonic
cosmos-empty = (empty)
cosmos-tile-carta = chart
cosmos-tile-modulos = modules
cosmos-tile-armonico = harmonic
cosmos-tile-cuerpos = bodies
cosmos-tile-aspectos = aspects
cosmos-tile-box-graph = aspectarian
cosmos-tile-cualidades = qualities
cosmos-elementos = elements
cosmos-modalidades = modalities
cosmos-polaridad = polarity
cosmos-elem-fuego = fire
cosmos-elem-tierra = earth
cosmos-elem-aire = air
cosmos-elem-agua = water
cosmos-mod-cardinal = cardinal
cosmos-mod-fijo = fixed
cosmos-mod-mutable = mutable
cosmos-pol-yang = yang
cosmos-pol-yin = yin
cosmos-tile-astrocarto = astrocartography
cosmos-astrocarto-leyenda = MC solid · IC dashed · Asc/Desc curves · • natal place
cosmos-tile-cartas = saved charts
cosmos-cartas-duplicar = + duplicate current
cosmos-cartas-vacio = (empty — duplicate current or drop JSONs in the dir)
cosmos-tile-corpus = corpus
cosmos-tile-lotes = lots
cosmos-tile-estrellas-fijas = fixed stars
cosmos-tile-puntos-medios = midpoints
cosmos-corpus-header = { $pasajes } passages · { $huecos } gaps · { $total } combos
cosmos-corpus-vacio = (no passages — write your corpus in cosmos-corpus/ejemplo.ron)
cosmos-tile-uraniano = uranian dial 90°
cosmos-tile-cross-transit = cross · transit
cosmos-tile-cross-progression = cross · progression
cosmos-tile-cross-solar-arc = cross · solar arc
# === wawa-explorer (Wawa image browser) ===
wawa-marker-via-aoe = · via AoE
wawa-marker-searching = · searching…
wawa-marker-fetch-failed = · fetch failed
wawa-marker-not-in-image = · (not in image)
wawa-iface-ok = · AoE iface: { $name }
wawa-iface-err = · AoE: no interface
wawa-header-error = wawa-explorer · error: { $err }
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } objects{ $iface }
wawa-detail-empty = (select an object from the tree)
wawa-detail-title = object { $hash } · { $bytes } bytes · { $children } children{ $origen }
wawa-detail-title-missing = object { $hash } · not present locally
wawa-detail-payload-header = payload (first 256 bytes):
wawa-detail-children-header = children:
wawa-detail-child-missing = (not in image)
wawa-detail-searching-aoe-1 = searching the local network (AoE)…
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, awaiting ProveedorObjeto with verified hash.
wawa-detail-fetch-error-1 = last AoE attempt failed:
wawa-detail-fetch-error-2 = you can retry with the button below.
wawa-detail-needs-fetch-1 = this object is referenced by a parent but does not live in the local image.
wawa-detail-needs-fetch-2 = you can request it from Wawa peers on the local network (AoE, iface `{ $iface }`).
wawa-detail-aoe-disabled-1 = this object is referenced by a parent but does not live in the local image.
wawa-detail-aoe-disabled-2 = AoE disabled: { $why }
wawa-detail-aoe-disabled-3 = pass `<iface>` as the second CLI argument or run with CAP_NET_RAW (`sudo setcap cap_net_raw=eip <binary>`).
wawa-btn-fetch = fetch from peers
wawa-btn-retry-fetch = retry fetch from peers
# main menu bar
wawa-menu-file = File
wawa-menu-reload = Reload image
wawa-menu-quit = Quit
wawa-menu-view = View
wawa-menu-fetch = Fetch node via AoE
wawa-menu-theme = Toggle theme
wawa-menu-help = Help
wawa-menu-about = About
# context menu on the selected node
wawa-ctx-select = Select
wawa-ctx-expand = Expand
wawa-ctx-collapse = Collapse
wawa-ctx-fetch = Fetch via AoE
# === minga-explorer (repo browser) ===
minga-header-loaded = Repo: { $path } · reload { $ms } ms
minga-header-searching = Searching repo at { $path }…
minga-error-read = could not read repo { $path }: { $err }
minga-card-nodes-title = AST Nodes
minga-card-nodes-desc = code fragments parsed
minga-card-attestations-title = Attestations
minga-card-attestations-desc = Ed25519 signatures over nodes
minga-card-mst-title = MST Keys
minga-card-mst-desc = Merkle Search Tree entries
minga-empty = Waiting for first refresh…
minga-menu-file = File
minga-menu-view = View
minga-menu-help = Help
minga-menu-refresh = Refresh
minga-menu-quit = Quit
minga-menu-theme = Toggle theme
minga-menu-about = About
minga-menu-context-title = Repo
# === nakui-explorer (event log) ===
nakui-explorer-header = Log: { $path } · { $entries } entries ({ $seeds } seeds, { $morphisms } morphisms) · reload { $ms } ms
nakui-explorer-breakdown = breakdown: { $parts }
# === supay (doom) ===
supay-mode-real = REAL ENGINE
supay-mode-stub = STUB
supay-view-fb = view=FB (F3→3D)
supay-view-3d = view=3D (F3→FB)
supay-header = { $title } · tick { $tick } · { $mode } · { $view } · { $scene }
supay-stub-title = supay-doom-llimphi is running in STUB mode
supay-stub-step-1 = Clone doomgeneric
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
supay-stub-step-2 = Drop the shareware WAD in the cwd
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
supay-stub-step-3 = Run it again
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
supay-stub-footer = doomgeneric (C) ticks at 35 Hz; the 320×200 ARGB framebuffer paints in aspect-fit.
supay-controls-hint = WASD · Ctrl fire · Space use · Tab map · F3 view · F4 cross · F5 vig · F6 HUD · F7 shadows · F8 muzzle · F9 occl · F10 mobj-lit · F11 rim · F12 quit
supay-stub-controls-hint = F3 toggles FB/3D · F12 closes the window
# === shuma-shell ===
shuma-label-launcher = Launcher
shuma-label-command = Command
shuma-label-shell = Shell
shuma-label-matilda = Matilda
shuma-label-canvas = Canvas
shuma-label-monitors = Monitors
shuma-empty-main-incompat = Main module not compatible
shuma-empty-no-tabs = No tabs configured.
shuma-empty-no-tabs-compat = This module cannot be a tab.
shuma-empty-no-data-linux = no data (not Linux?)
shuma-empty-no-data = no data
shuma-stat-samples = samples: { $have } / { $total }
# === nahual (viewers) ===
nahual-image-unsupported = unsupported format (only PNG/JPEG in this build)
# === greeter (mirada login) ===
greeter-subtitle = sign in
greeter-label-user = username
greeter-label-password = password
greeter-placeholder-user = enter your username
greeter-status-authenticating = verifying…
greeter-error-empty-user = enter a username
# === nakui (ERP shell) ===
nakui-header = Nakui · { $count } module(s)
nakui-sidebar-modules = Modules ({ $count })
nakui-sidebar-menu = Menu
nakui-empty-no-modules = No modules loaded
nakui-empty-pick-module = Pick a module in the sidebar
nakui-empty-pick-menu = Pick a menu in the sidebar
nakui-pending-edit = edit pending: requires Llimphi meta-form
nakui-pending-render-detail = render pending: requires Llimphi meta-form
nakui-pending-render-dashboard = render pending: requires Llimphi dashboard
# === pluma (DAG editor) ===
pluma-tone-valid = coherent
pluma-tone-pending = pending
pluma-tone-conflict = conflict
# === gioser-edit (code editor) ===
edit-status-find = find · Ctrl+G next · Esc closes
edit-status-goto-def-waiting = goto-def · waiting for LSP…
edit-status-references-waiting = references · waiting for LSP…
edit-status-rename-input = rename · Enter applies · Esc cancels
edit-status-rename-waiting = rename → «{ $name }» · waiting for LSP…
edit-status-rename-error = rename · error in { $path }: { $err }
edit-status-rename-done = rename · { $files } files · { $bytes } bytes
edit-status-formatting-waiting = formatting · waiting for LSP…
edit-status-formatting-done = formatting · applied
edit-status-goto-def-at = goto-def · { $path }:{ $line }
edit-status-goto-def-error = goto-def · error opening { $path }: { $err }
edit-status-saved = saved · { $path }
edit-status-save-error = save error: { $err }
edit-header-hint = Ctrl+Shift+P palette · Ctrl+P files · Ctrl+Shift+F search
edit-status-position = Ln { $line }, Col { $col } · { $lang }
# === chasqui-explorer (monads) ===
chasqui-header = Engine '{ $engine }' · { $count } monad(s) · socket: { $socket } ({ $src }){ $watching }
chasqui-header-watching = · watching: { $name }
chasqui-header-searching = Searching chasqui daemon via brahman-broker…
chasqui-field-id = id: { $id }
chasqui-field-watching = watching: { $name }
chasqui-field-keywords = keywords: { $keywords }
chasqui-field-path = path: { $path }
chasqui-field-model = model: { $name }
# === wawa-panel (wawa OS control panel) ===
wawa-panel-title = wawa · control panel
wawa-panel-cat-appearance = Appearance
wawa-panel-cat-language = Language
wawa-panel-cat-apps = Applications
wawa-panel-cat-monitor = Monitor
wawa-panel-cat-modules = Modules
wawa-panel-cat-about = About
wawa-panel-section-appearance-hint = Theme variant and accent.
wawa-panel-section-language-hint = System language and clock format.
wawa-panel-section-apps-hint = Launch wawa native apps.
wawa-panel-section-monitor-hint = Live system state.
wawa-panel-section-modules-hint = Enable or disable OS modules.
wawa-panel-section-about-hint = Operating system information.
wawa-panel-label-variant = Variant
wawa-panel-label-accent = Accent
wawa-panel-label-language = Language
wawa-panel-label-clock = Clock
wawa-panel-variant-dark = Dark
wawa-panel-variant-light = Light
wawa-panel-variant-aurora = Aurora
wawa-panel-variant-sunset = Sunset
wawa-panel-clock-24h = 24 h
wawa-panel-clock-12h = 12 h
wawa-panel-stat-time = Time
wawa-panel-stat-uptime = Uptime
wawa-panel-stat-mem = Memory
wawa-panel-stat-load = Load
wawa-panel-stat-host = Host
wawa-panel-stat-kernel = Kernel
wawa-panel-action-launch = Launch
wawa-panel-action-save = Save config
wawa-panel-action-reset = Reset
wawa-panel-saved = Configuration saved to { $path }
wawa-panel-reset = Configuration reset to defaults
wawa-panel-menu-file = File
wawa-panel-menu-view = View
wawa-panel-menu-help = Help
wawa-panel-menu-quit = Quit
wawa-panel-status-hint = ↑↓ navigate · Enter activate · Ctrl+S save · Esc exit
wawa-panel-about-name = System
wawa-panel-about-version = Version
wawa-panel-about-kernel = Kernel
wawa-panel-about-toolkit = Toolkit
wawa-panel-about-blurb = wawa is the operating system of the gioser suite. arje kernel and llimphi apps over a minimal userland.
wawa-panel-mod-mirada = mirada · wayland compositor
wawa-panel-mod-shuma = shuma · packaging and releases
wawa-panel-mod-chasqui = chasqui · mail and messaging
wawa-panel-mod-akasha = akasha · update channel
wawa-panel-mod-minga = minga · p2p storage
wawa-panel-mod-agora = agora · public square
wawa-panel-mod-on = on
wawa-panel-mod-off = off
# === mirada-asistente ===
# Llimphi app that translates natural language into mirada-ctl commands
# by consulting an LLM. The AI proposes; the human confirms before executing.
asistente-title = carmen · assistant
asistente-sub = tell me what you want to do; the assistant proposes, you confirm.
asistente-placeholder = what do you want to do? (Enter to ask, Esc to clear)
asistente-banner-no-llm = LLM unavailable: { $motivo }
asistente-status-pensando = thinking…
asistente-boton-ejecutar = Run
asistente-boton-descartar = Discard
asistente-ejecutado-ok = ✓ { $accion } executed
asistente-ejecutado-fallo = ✗ { $accion } failed
asistente-error-transporte = transport: { $motivo }
asistente-error-sin-llm = LLM not initialized
asistente-error-sin-json = response without JSON: { $crudo }
asistente-error-accion-vacia = proposal without action: { $crudo }
asistente-error-json-invalido = unrecognized JSON: { $crudo }
asistente-error-spawn = spawn failed: { $err } (is mirada-ctl in PATH?)
asistente-cero-salida = (no output)
asistente-codigo-salida = exit code { $codigo }
asistente-error-accion-desconocida = LLM proposed an unknown action: { $accion }
# === ayni-llimphi ===
ayni-menu-admitir = Admit selected
ayni-menu-atestar = Attest selected
ayni-menu-expulsar = Expel selected
ayni-menu-enviar-msg = Send message
ayni-menu-adjuntar = Attach file…
ayni-menu-acuse = Read receipt
ayni-menu-cifrado = E2EE Encryption
ayni-menu-recibos = Read receipts
ayni-menu-comandos-barra = Slash commands
ayni-label-gente-miembros = PEOPLE — members
ayni-label-otros-vistos = others seen
ayni-label-acciones = actions
ayni-label-elige-alguien = pick someone above
ayni-btn-admitir = admit
ayni-btn-atestar = attest
ayni-btn-expulsar = expel
ayni-btn-acuse = receipt
ayni-label-confianza = trust (hops)
ayni-label-sin-atestaciones = — no attestations —
ayni-label-sin-mensajes = — no messages. Type below (or /help for commands). —
ayni-compose-placeholder = type a message, or /adjuntar <path>, /atestar <hex> …
ayni-btn-enviar = send
# === chaka-app-llimphi ===
chaka-menu-run = Run
chaka-menu-run-pipeline = Run pipeline
chaka-tab-output = Output
chaka-tab-rust = Generated Rust
chaka-tab-diag = Diagnostics
chaka-btn-run = Run
chaka-corpus-empty = empty corpus
chaka-corpus-header = CORPUS
chaka-editor-placeholder = select a program from the corpus
chaka-no-file = no file
chaka-banner-open-corpus = open a program from the corpus on the left
chaka-banner-step-limit = shadow ⚠ step limit reached (infinite loop?)
chaka-banner-pipeline-error = the pipeline failed — see the «Diag» tab for details
chaka-status-no-open-file = no open file to save
chaka-about-text = chaka · COBOL → Rust transpiler · pipeline lex→parse→ir→codegen→shadow
# === chasqui-explorer-llimphi ===
chasqui-explorer-ctx-detail = View detail
chasqui-explorer-monad-label = Monad
chasqui-explorer-monad-stats = { $count } files · ent { $entropy } · { $lens }
# === media-app ===
media-settings-tab-audio = Audio
media-settings-tab-video = Video
media-settings-tab-playback = Playback
media-settings-tab-bars = Bars
media-settings-tab-controls = Controls
media-audio-volume = Volume
media-audio-eq = Equalizer
media-audio-normalization = Normalization
media-audio-lufs-target = LUFS Target
media-audio-downmix = Stereo downmix
media-video-color = Color
media-video-enable = Enable
media-video-brightness = Brightness
media-video-contrast = Contrast
media-video-gamma = Gamma
media-video-saturation = Saturation
media-video-hue = Hue
media-video-orientation = Orientation
media-video-rotation = Rotation
media-video-rotate-cw = rotate 90°
media-video-flip-h = Flip H
media-video-flip-v = Flip V
media-action-reset = reset
media-action-cycle = cycle
media-playback-playlist = Playlist
media-playback-resume = Resume on open
media-playback-repeat = Repeat
media-playback-shuffle = Shuffle
media-playback-subtitles = Subtitles
media-playback-autoload-sidecar = Auto-load sidecar
media-playback-sub-delay = Delay (ms)
media-playback-font-size = Font size
media-playback-behavior = Behavior
media-playback-crossfade = Crossfade (s)
media-controls-header = Controls (keyboard)
media-controls-hint = Edit controles.ron and press F5 to reassign keys. The visual shortcut editor is coming later.
media-bars-header = Control bars — click an item to remove it
media-bars-bar-label = Bar
media-bars-remove-bar = remove bar
media-bars-add-bar = + new bar
media-bars-add-items-to = Add items to:
media-settings-footer = Saved to config.ron · Esc closes · in Bars: click an item to remove, reorder
media-playlist-header = Playlist — click a track to jump
media-playlist-empty = No playlist.
media-win-config-title = Settings — media
media-win-playlist-title = Playlist — media
media-help-title = media · shortcuts
media-help-group-playback = Playback
media-help-toggle = Show/hide this help
media-help-close = Close help
media-help-reload = Hot-reload controles.ron
media-menu-capture-frame = Capture frame
media-menu-record = Record / stop
media-menu-reload-controls = Reload controls
media-menu-playback = Playback
media-menu-play-pause = Play / pause
media-menu-seek-back = Seek backward
media-menu-seek-fwd = Seek forward
media-menu-prev-track = Previous track
media-menu-next-track = Next track
media-menu-volume-up = Volume up
media-menu-volume-down = Volume down
media-menu-playlist = Playlist
media-menu-visualizers = Audio visualizers
media-menu-shortcuts-help = Shortcut help
media-ctx-stop-record = Stop recording
media-ctx-record-audio = Record audio
# === mirada-app-llimphi ===
mirada-menu-open-window = Open window
mirada-menu-open-output = Open monitor
mirada-menu-close-focused = Close focused
mirada-menu-window = Window
mirada-win-promote = Promote to master
mirada-win-float = Float / anchor
mirada-win-fullscreen = Full screen
mirada-win-scratchpad = Send to scratchpad
mirada-win-label-fallback = window
mirada-layout-cycle = Cycle layout
mirada-layout-master-stack = Master + stack
mirada-layout-monocle = Monocle
mirada-layout-grid = Grid
mirada-layout-columns = Columns
mirada-layout-rows = Rows
mirada-layout-centered = Centered master
mirada-layout-spiral = Spiral
mirada-layout-shrink = Shrink master
mirada-layout-grow = Grow master
mirada-output-next = Next monitor
mirada-status-body-connected = Body connected
mirada-status-simulation = simulation — no Body
mirada-status-keymap-reloaded = keymap reloaded
mirada-status-keymap-invalid = invalid keymap
mirada-label-layout = layout
mirada-label-focus = focus
mirada-label-output = output
mirada-label-workspace = workspace
mirada-canvas-empty-hint = empty workspace — press n to open a window
mirada-win-kind-fullscreen = · full screen ·
mirada-win-kind-floating = · floating window ·
mirada-win-kind-surface = · body surface ·
# === mirada-greeter ===
mirada-greeter-menu-session = Session
mirada-greeter-session-submit = Log in
mirada-greeter-session-goto-user = Go to username
mirada-greeter-session-goto-pass = Go to password
mirada-greeter-label-desktop = Desktop
mirada-greeter-btn-submit = Log in
mirada-greeter-btn-submitting = Logging in…
mirada-greeter-hint-nav = ↑/↓: desktop · Enter: log in
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: console · Ctrl+Alt+⌫: exit
# === nakui-explorer-llimphi ===
nakui-explorer-menu-refresh-log = Refresh log
nakui-explorer-ctx-view-detail = View detail
nakui-explorer-ctx-refresh-log = Refresh log
nakui-explorer-ctx-entry-fallback = Entry
# === nakui-sheet-llimphi ===
nakui-sheet-ctx-clear = Clear
nakui-sheet-fmt-number = Format: Number
nakui-sheet-fmt-currency = Format: Currency $
nakui-sheet-fmt-percent = Format: Percentage
nakui-sheet-fmt-general = Format: General
nakui-sheet-freeze-here = Freeze Panes Here
nakui-sheet-unfreeze = Unfreeze Panes
nakui-sheet-pivot = Pivot Table…
nakui-sheet-menu-cell-cut = Cut Cell
nakui-sheet-menu-cell-copy = Copy Cell
nakui-sheet-menu-cell-paste = Paste Cell
nakui-sheet-menu-cell-clear = Clear Cell
nakui-sheet-menu-bar-cut = Cut Text
nakui-sheet-menu-bar-copy = Copy Text
nakui-sheet-menu-bar-paste = Paste Text
nakui-sheet-menu-bar-select-all = Select All (text)
nakui-sheet-menu-import-csv = Import CSV
nakui-sheet-menu-export-csv = Export CSV
nakui-sheet-menu-about = About Nakui Sheet
nakui-sheet-formula-placeholder = enter formula or value
nakui-sheet-pivot-title = Pivot Table
nakui-sheet-pivot-close = ✕ Esc
nakui-sheet-pivot-group-by = Group by «
nakui-sheet-pivot-over = over
nakui-sheet-pivot-with-header = w/header
nakui-sheet-pivot-no-header = no header
nakui-sheet-pivot-more-groups = groups
nakui-sheet-pivot-total = TOTAL
nakui-sheet-pivot-groups = groups
nakui-sheet-pivot-rows = rows
nakui-sheet-pivot-hint = A function · G group · V value · H header · Esc close
# === paloma-llimphi ===
paloma-status-init = paloma · not synced
paloma-status-search-semantic = semantic search (rimay): pending — using exact
paloma-status-view-rich = rich HTML via puriy: pending (plain text for now)
paloma-status-no-recipient = cannot send: missing a valid recipient
paloma-status-sent = sent
paloma-status-sent-signed = sent · signed (Ed25519)
paloma-placeholder-search = Search… ( / )
paloma-btn-compose = ✎ Compose
paloma-nav-calendar = Calendar
paloma-nav-contacts = Contacts
paloma-nav-soon = soon
paloma-empty-threads = Empty inbox
paloma-empty-search = no matches
paloma-search-exact = Exact
paloma-search-semantic = Semantic
paloma-no-subject = (no subject)
paloma-placeholder-read = Select a thread to read it
paloma-btn-reply = Reply
paloma-btn-forward = Forward
paloma-btn-star = Star
paloma-btn-starred = Starred
paloma-btn-mark-unread = Mark as unread
paloma-btn-mark-read = Mark as read
paloma-btn-view-rich = View rich HTML
paloma-msg-to-label = to
paloma-sig-verified = signed
paloma-sig-invalid = invalid signature
paloma-compose-new = New message
paloma-compose-reply-title = Reply
paloma-compose-placeholder-to = To: name <email@domain>
paloma-compose-placeholder-cc = Cc: (optional)
paloma-compose-placeholder-subject = Subject
paloma-compose-placeholder-body = Write your message…
paloma-compose-sign = Sign (Ed25519)
paloma-compose-send = Send
# === pluma-notebook-llimphi ===
pluma-notebook-fit-all = Fit All
pluma-notebook-center = Center
pluma-notebook-zoom-reset = Zoom 100%
# === raymi-llimphi ===
raymi-tab-calendar = Calendar
raymi-tab-contacts = Contacts
raymi-view-month = Month
raymi-view-week = Week
raymi-view-day = Day
raymi-btn-new-event = Event
raymi-btn-today = Today
raymi-btn-new-contact = Contact
raymi-no-events = no events
raymi-all-day = all day
raymi-no-contacts = no contacts
raymi-search-contact-placeholder = 🔍 Search contact…
raymi-select-contact-hint = Select a contact
raymi-title-edit-event = Edit event
raymi-title-new-event = New event
raymi-title-edit-contact = Edit contact
raymi-title-new-contact = New contact
raymi-change-cycle = change
raymi-field-summary = Subject
raymi-field-all-day = All day
raymi-field-apply-to = Apply to
raymi-field-calendar = Calendar
raymi-field-date = Date
raymi-field-start = Start
raymi-field-end = End
raymi-field-location = Location
raymi-ph-location = Location (optional)
raymi-field-description = Description
raymi-ph-description = Notes (optional)
raymi-field-attendees = Attendees
raymi-ph-invitee = Name <email> · Enter
raymi-field-repeat = Repeat
raymi-field-every = Every
raymi-field-days = Days
raymi-field-ends = Ends
raymi-field-name = Name
raymi-field-emails = Emails
raymi-field-phones = Phones
raymi-field-org = Organization
raymi-field-note = Note
raymi-ph-full-name = Full name
raymi-ph-emails = email@domain, other@…
raymi-ph-phones = +1 555…, …
raymi-ph-org = Company (optional)
raymi-ph-note = Note (optional)
raymi-scope-series = All events in series
raymi-scope-this-only = This event only
raymi-scope-this-and-future = This and following events
raymi-repeat-none = Does not repeat
raymi-repeat-daily = Daily
raymi-repeat-weekly = Weekly
raymi-repeat-monthly = Monthly
raymi-repeat-yearly = Yearly
raymi-unit-days = day(s)
raymi-unit-weeks = week(s)
raymi-unit-months = month(s)
raymi-unit-years = year(s)
raymi-end-never = Never ends
raymi-end-count = After N times
raymi-end-until = Until date
raymi-status-no-calendars = no calendars available to create an event
raymi-status-no-books = no address books available to create a contact
raymi-status-invalid-datetime = invalid date or time (use YYYY-MM-DD and HH:MM)
raymi-status-contact-needs-name = contact requires a name
# === shuma-shell-llimphi ===
shuma-shell-clear-input = Clear input
shuma-shell-clear-screen = Clear screen
shuma-shell-cancel-cmd = Cancel command
shuma-shell-about = About shuma
# === supay-app-llimphi ===
supay-hud-health = HEALTH
supay-hud-ammo = AMMO
supay-hud-target = TARGET
supay-action-fire = Fire
supay-action-reset = Restart game
supay-menu-play = Play
supay-status-game-over = game over
supay-status-victory = victory
supay-status-dead = DEAD
supay-hint-space-restart = SPACE to restart
# === wawa-panel-llimphi ===
wawa-panel-status-config-updated = ↻ config updated from bus
wawa-panel-ctx-refresh-monitor = Refresh monitor
wawa-panel-autosave-ok = ↻ applied
+723
View File
@@ -0,0 +1,723 @@
# rimay-localize — catálogo es-PE
# Convención: IDs en kebab-case, ASCII, en inglés (estables); traducción
# en este archivo. Comentarios (#) describen contexto cuando el ID no
# basta.
# === acciones genéricas ===
save = Guardar
load = Cargar
open = Abrir
close = Cerrar
cancel = Cancelar
confirm = Aceptar
yes = Sí
no = No
delete = Eliminar
edit = Editar
new = Nuevo
# === estado ===
play = Reproducir
pause = Pausar
resume = Reanudar
stop = Detener
# === menús ===
file = Archivo
view = Vista
help = Ayuda
settings = Configuración
exit = Salir
# === chrome común (reutilizable por todas las apps Llimphi) ===
# Etiquetas de menú/acción compartidas. Una app sólo crea IDs propios
# (`<app>-*`) para texto que no aparezca acá.
search = Buscar
language = Idioma
undo = Deshacer
redo = Rehacer
cut = Cortar
copy = Copiar
paste = Pegar
select-all = Seleccionar todo
open-dots = Abrir…
save-as = Guardar como…
close-tab = Cerrar pestaña
find-in-file = Buscar en archivo
find-in-project = Buscar en proyecto
symbols = Símbolos
goto-definition = Ir a definición
terminal = Terminal
command-palette = Paleta de comandos
minimap = Minimapa
cycle-theme = Cambiar tema
editing = Edición
about = Acerca de
refresh = Refrescar
reconnect = Reconectar
# === nada (editor de archivos) ===
nada-tagline = editor soberano sobre Llimphi
# === niveles de mensaje ===
info = Información
warning = Advertencia
error = Error
success = Listo
# === interpolación ===
welcome-user = Bienvenido, { $name }.
items-count = { $count } elementos.
# === dominium (simulador de campo medio) ===
dominium-status-running = ● corriendo
dominium-status-paused = ‖ en pausa
dominium-status-line = dominium · campo medio · época { $epoch } · tick { $tick }
dominium-btn-pause = ‖ Pausar
dominium-btn-resume = ▶ Reanudar
dominium-btn-reseed = ↺ Re-sembrar
dominium-btn-create-concept = ✦ Crear concepto
dominium-btn-seed-pack = ✚ Sembrar pack
dominium-btn-clear = ✖ Limpiar
dominium-btn-save = 💾 Guardar
dominium-btn-load-saved = 📂 Cargar guardado
dominium-btn-load-named = ✓ Cargar «{ $name }»
dominium-header-sim = [ SIM ]
dominium-header-conceptos = [ CONCEPTOS ]
dominium-header-metricas = [ MÉTRICAS ]
dominium-header-editar = [ EDITAR ]
dominium-active-count = { $count } activos
dominium-stat-population = Población
dominium-stat-materia = Materia
dominium-stat-oro = Oro
dominium-stat-energia = Energía
dominium-stat-epoca = Época
dominium-stat-gini-energia = Gini energía
dominium-stat-edad-media = Edad media
dominium-stat-var-psi-orden = Var ψ orden
dominium-stat-var-psi-miedo = Var ψ miedo
dominium-stat-var-psi-curiosidad = Var ψ curiosidad
dominium-stat-var-psi-corruptib = Var ψ corruptib.
dominium-action-mover = → mover
dominium-action-extraer = → extraer
dominium-action-sincronizar = → sincronizar
dominium-action-intercambiar = → intercambiar
dominium-action-replicar = → replicar
dominium-action-degradar = → degradar
dominium-slider-nombre = nombre
dominium-slider-radius = radius
dominium-slider-materia = materia
dominium-slider-psique = psique
dominium-slider-poder = poder
dominium-slider-oro = oro
dominium-label-hack = hack:
# === cosmos (módulos overlay) ===
cosmos-btn-save-transit = 💾 Guardar tránsito como carta libre
cosmos-btn-save-progressed = 💾 Guardar progresada como carta libre
cosmos-btn-save-return = 💾 Guardar retorno como carta libre
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
cosmos-demo-title = Carta de muestra (Lima)
cosmos-demo-subtitle = computada por cosmos-engine (VSOP2013)
cosmos-status = { $ms } ms · { $layers } capas · { $overlays } overlays · { $aspects } aspectos
cosmos-status-error = error: { $err }
cosmos-overlay-transit = tránsito
cosmos-overlay-progression = progresión
cosmos-overlay-solar-arc = arco solar
cosmos-overlay-uranian = uraniano
cosmos-overlay-lots = lotes
cosmos-overlay-fixed-stars = est. fijas
cosmos-overlay-midpoints = puntos medios
cosmos-harmonic-label = armónico
cosmos-empty = (vacío)
cosmos-tile-carta = carta
cosmos-tile-modulos = módulos
cosmos-tile-armonico = armónico
cosmos-tile-cuerpos = cuerpos
cosmos-tile-aspectos = aspectos
cosmos-tile-box-graph = aspectarian
cosmos-tile-cualidades = cualidades
cosmos-elementos = elementos
cosmos-modalidades = modalidades
cosmos-polaridad = polaridad
cosmos-elem-fuego = fuego
cosmos-elem-tierra = tierra
cosmos-elem-aire = aire
cosmos-elem-agua = agua
cosmos-mod-cardinal = cardinal
cosmos-mod-fijo = fijo
cosmos-mod-mutable = mutable
cosmos-pol-yang = yang
cosmos-pol-yin = yin
cosmos-tile-astrocarto = astrocartografía
cosmos-astrocarto-leyenda = MC sólido · IC punteado · Asc/Desc curvas · • lugar natal
cosmos-tile-cartas = cartas guardadas
cosmos-cartas-duplicar = + duplicar la actual
cosmos-cartas-vacio = (vacío — duplicá la actual o copiá JSONs al dir)
cosmos-tile-corpus = corpus
cosmos-tile-lotes = lotes
cosmos-tile-estrellas-fijas = estrellas fijas
cosmos-tile-puntos-medios = puntos medios
cosmos-corpus-header = { $pasajes } pasajes · { $huecos } huecos · { $total } combinaciones
cosmos-corpus-vacio = (sin pasajes — escribí el corpus en cosmos-corpus/ejemplo.ron)
cosmos-tile-uraniano = dial uraniano 90°
cosmos-tile-cross-transit = cross · tránsito
cosmos-tile-cross-progression = cross · progresión
cosmos-tile-cross-solar-arc = cross · arco solar
# === wawa-explorer (Wawa image browser) ===
wawa-marker-via-aoe = · via AoE
wawa-marker-searching = · buscando…
wawa-marker-fetch-failed = · fetch falló
wawa-marker-not-in-image = · (no en imagen)
wawa-iface-ok = · AoE iface: { $name }
wawa-iface-err = · AoE: sin interfaz
wawa-header-error = wawa-explorer · error: { $err }
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } objetos{ $iface }
wawa-detail-empty = (seleccioná un objeto del tree)
wawa-detail-title = objeto { $hash } · { $bytes } bytes · { $children } hijos{ $origen }
wawa-detail-title-missing = objeto { $hash } · no presente localmente
wawa-detail-payload-header = payload (primeros 256 bytes):
wawa-detail-children-header = hijos:
wawa-detail-child-missing = (no en imagen)
wawa-detail-searching-aoe-1 = buscando en la red local (AoE)…
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, espera ProveedorObjeto con hash verificado.
wawa-detail-fetch-error-1 = último intento de AoE falló:
wawa-detail-fetch-error-2 = podés reintentar con el botón debajo.
wawa-detail-needs-fetch-1 = este objeto está referenciado por un padre pero no vive en la imagen local.
wawa-detail-needs-fetch-2 = podés pedirlo a peers Wawa de la red local (AoE, iface `{ $iface }`).
wawa-detail-aoe-disabled-1 = este objeto está referenciado por un padre pero no vive en la imagen local.
wawa-detail-aoe-disabled-2 = AoE deshabilitado: { $why }
wawa-detail-aoe-disabled-3 = pasá `<iface>` como segundo argumento de CLI o ejecutá con CAP_NET_RAW (`sudo setcap cap_net_raw=eip <binario>`).
wawa-btn-fetch = fetch from peers
wawa-btn-retry-fetch = reintentar fetch from peers
# menú principal
wawa-menu-file = Archivo
wawa-menu-reload = Recargar imagen
wawa-menu-quit = Salir
wawa-menu-view = Ver
wawa-menu-fetch = Traer nodo por AoE
wawa-menu-theme = Cambiar tema
wawa-menu-help = Ayuda
wawa-menu-about = Acerca de
# menú contextual sobre el nodo seleccionado
wawa-ctx-select = Seleccionar
wawa-ctx-expand = Expandir
wawa-ctx-collapse = Contraer
wawa-ctx-fetch = Traer por AoE
# === minga-explorer (repo browser) ===
minga-header-loaded = Repo: { $path } · reload { $ms } ms
minga-header-searching = Buscando repo en { $path }…
minga-error-read = no pude leer repo { $path }: { $err }
minga-card-nodes-title = Nodos AST
minga-card-nodes-desc = fragments parseados del código
minga-card-attestations-title = Atestaciones
minga-card-attestations-desc = firmas Ed25519 sobre los nodos
minga-card-mst-title = Claves MST
minga-card-mst-desc = entradas del Merkle Search Tree
minga-empty = Esperando primer refresh…
minga-menu-file = Archivo
minga-menu-view = Ver
minga-menu-help = Ayuda
minga-menu-refresh = Refrescar
minga-menu-quit = Salir
minga-menu-theme = Cambiar tema
minga-menu-about = Acerca de
minga-menu-context-title = Repo
# === nakui-explorer (event log) ===
nakui-explorer-header = Log: { $path } · { $entries } entries ({ $seeds } seeds, { $morphisms } morphisms) · reload { $ms } ms
nakui-explorer-breakdown = breakdown: { $parts }
# === supay (doom) ===
supay-mode-real = ENGINE REAL
supay-mode-stub = STUB
supay-view-fb = view=FB (F3→3D)
supay-view-3d = view=3D (F3→FB)
supay-header = { $title } · tick { $tick } · { $mode } · { $view } · { $scene }
supay-stub-title = supay-doom-llimphi corre en modo STUB
supay-stub-step-1 = Cloná doomgeneric
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
supay-stub-step-2 = Bajá el WAD shareware al cwd
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
supay-stub-step-3 = Volvé a correr
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
supay-stub-footer = doomgeneric (C) avanza a 35 Hz; el framebuffer 320×200 ARGB se pinta en aspect-fit.
supay-controls-hint = WASD · Ctrl disp · Space usa · Tab map · F3 vista · F4 mira · F5 viñeta · F6 HUD · F7 sombras · F8 fogonazo · F9 oclusión · F10 luz-mobj · F11 rim-arma · F12 salir
supay-stub-controls-hint = F3 alterna FB/3D · F12 cierra la ventana
# === shuma-shell ===
shuma-label-launcher = Launcher
shuma-label-command = Command
shuma-label-shell = Shell
shuma-label-matilda = Matilda
shuma-label-canvas = Lienzo
shuma-label-monitors = Monitores
shuma-empty-main-incompat = Módulo Main no compatible
shuma-empty-no-tabs = Sin tabs configuradas.
shuma-empty-no-tabs-compat = Este módulo no puede ser tab.
shuma-empty-no-data-linux = sin datos (¿no es Linux?)
shuma-empty-no-data = sin datos
shuma-stat-samples = muestras: { $have } / { $total }
# === nahual (visores) ===
nahual-image-unsupported = formato no soportado (sólo PNG/JPEG en esta build)
# === greeter (mirada login) ===
greeter-subtitle = iniciá tu sesión
greeter-label-user = usuario
greeter-label-password = contraseña
greeter-placeholder-user = ingresá tu usuario
greeter-status-authenticating = verificando…
greeter-error-empty-user = ingresá un usuario
# === nakui (ERP shell) ===
nakui-header = Nakui · { $count } módulo(s)
nakui-sidebar-modules = Módulos ({ $count })
nakui-sidebar-menu = Menú
nakui-empty-no-modules = Sin módulos cargados
nakui-empty-pick-menu = Elegí un menú en la barra lateral
nakui-empty-pick-module = Elegí un módulo en la barra lateral
nakui-pending-edit = edición pendiente: requiere meta-form Llimphi
nakui-pending-render-detail = render pendiente: requiere meta-form Llimphi
nakui-pending-render-dashboard = render pendiente: requiere dashboard Llimphi
# === pluma (editor DAG) ===
pluma-tone-valid = coherente
pluma-tone-pending = por evaluar
pluma-tone-conflict = en conflicto
# === gioser-edit (editor de código) ===
edit-status-find = find · Ctrl+G siguiente · Esc cierra
edit-status-goto-def-waiting = goto-def · esperando LSP…
edit-status-references-waiting = references · esperando LSP…
edit-status-rename-input = rename · Enter aplica · Esc cancela
edit-status-rename-waiting = rename → «{ $name }» · esperando LSP…
edit-status-rename-error = rename · error en { $path }: { $err }
edit-status-rename-done = rename · { $files } archivos · { $bytes } bytes
edit-status-formatting-waiting = formatting · esperando LSP…
edit-status-formatting-done = formatting · aplicado
edit-status-goto-def-at = goto-def · { $path }:{ $line }
edit-status-goto-def-error = goto-def · error abriendo { $path }: { $err }
edit-status-saved = guardado · { $path }
edit-status-save-error = error guardando: { $err }
edit-header-hint = Ctrl+Shift+P palette · Ctrl+P files · Ctrl+Shift+F search
edit-status-position = Ln { $line }, Col { $col } · { $lang }
# === chasqui-explorer (mónadas) ===
chasqui-header = Engine '{ $engine }' · { $count } mónada(s) · socket: { $socket } ({ $src }){ $watching }
chasqui-header-watching = · watching: { $name }
chasqui-header-searching = Buscando daemon chasqui vía brahman-broker…
chasqui-field-id = id: { $id }
chasqui-field-watching = watching: { $name }
chasqui-field-keywords = keywords: { $keywords }
chasqui-field-path = path: { $path }
chasqui-field-model = model: { $name }
# === wawa-panel (panel de control del SO wawa) ===
wawa-panel-title = wawa · panel de control
wawa-panel-cat-appearance = Apariencia
wawa-panel-cat-language = Idioma
wawa-panel-cat-apps = Aplicaciones
wawa-panel-cat-monitor = Monitor
wawa-panel-cat-modules = Módulos
wawa-panel-cat-about = Acerca de
wawa-panel-section-appearance-hint = Variante del tema y acento.
wawa-panel-section-language-hint = Idioma del sistema y formato de hora.
wawa-panel-section-apps-hint = Lanzá las apps nativas de wawa.
wawa-panel-section-monitor-hint = Estado vivo del sistema.
wawa-panel-section-modules-hint = Activar o desactivar piezas del SO.
wawa-panel-section-about-hint = Información del sistema operativo.
wawa-panel-label-variant = Variante
wawa-panel-label-accent = Acento
wawa-panel-label-language = Idioma
wawa-panel-label-clock = Reloj
wawa-panel-variant-dark = Oscuro
wawa-panel-variant-light = Claro
wawa-panel-variant-aurora = Aurora
wawa-panel-variant-sunset = Sunset
wawa-panel-clock-24h = 24 h
wawa-panel-clock-12h = 12 h
wawa-panel-stat-time = Hora
wawa-panel-stat-uptime = Uptime
wawa-panel-stat-mem = Memoria
wawa-panel-stat-load = Carga
wawa-panel-stat-host = Host
wawa-panel-stat-kernel = Kernel
wawa-panel-action-launch = Lanzar
wawa-panel-action-save = Guardar config
wawa-panel-action-reset = Restablecer
wawa-panel-saved = Configuración guardada en { $path }
wawa-panel-reset = Configuración restablecida a valores por defecto
wawa-panel-menu-file = Archivo
wawa-panel-menu-view = Ver
wawa-panel-menu-help = Ayuda
wawa-panel-menu-quit = Salir
wawa-panel-status-hint = ↑↓ navegar · Enter activar · Ctrl+S guardar · Esc salir
wawa-panel-about-name = Sistema
wawa-panel-about-version = Versión
wawa-panel-about-kernel = Núcleo
wawa-panel-about-toolkit = Toolkit
wawa-panel-about-blurb = wawa es el sistema operativo de la suite gioser. El kernel arje y las apps llimphi sobre un userland mínimo.
wawa-panel-mod-mirada = mirada · compositor wayland
wawa-panel-mod-shuma = shuma · empaquetado y release
wawa-panel-mod-chasqui = chasqui · correo y mensajería
wawa-panel-mod-akasha = akasha · canal de actualizaciones
wawa-panel-mod-minga = minga · almacenamiento p2p
wawa-panel-mod-agora = agora · plaza pública
wawa-panel-mod-on = encendido
wawa-panel-mod-off = apagado
# === mirada-asistente ===
# App Llimphi que traduce lenguaje natural a comandos de mirada-ctl
# consultando un LLM. La IA propone, el humano confirma antes de ejecutar.
asistente-title = carmen · asistente
asistente-sub = describí lo que querés hacer; el asistente propone, vos confirmás.
asistente-placeholder = ¿qué querés hacer? (Enter para preguntar, Esc para limpiar)
asistente-banner-no-llm = LLM no disponible: { $motivo }
asistente-status-pensando = pensando…
asistente-boton-ejecutar = Ejecutar
asistente-boton-descartar = Descartar
asistente-ejecutado-ok = ✓ { $accion } ejecutado
asistente-ejecutado-fallo = ✗ { $accion } falló
asistente-error-transporte = transporte: { $motivo }
asistente-error-sin-llm = LLM no inicializado
asistente-error-sin-json = respuesta sin JSON: { $crudo }
asistente-error-accion-vacia = propuesta sin accion: { $crudo }
asistente-error-json-invalido = JSON no reconocido: { $crudo }
asistente-error-spawn = spawn falló: { $err } (¿está mirada-ctl en PATH?)
asistente-cero-salida = (sin salida)
asistente-codigo-salida = código { $codigo }
asistente-error-accion-desconocida = el LLM propuso una accion desconocida: { $accion }
# === ayni-llimphi ===
ayni-menu-admitir = Admitir seleccionado
ayni-menu-atestar = Atestar seleccionado
ayni-menu-expulsar = Expulsar seleccionado
ayni-menu-enviar-msg = Enviar mensaje
ayni-menu-adjuntar = Adjuntar archivo…
ayni-menu-acuse = Acuse de recibo
ayni-menu-cifrado = Cifrado E2EE
ayni-menu-recibos = Recibos de lectura
ayni-menu-comandos-barra = Comandos de la barra /
ayni-label-gente-miembros = GENTE — miembros
ayni-label-otros-vistos = otros vistos
ayni-label-acciones = acciones
ayni-label-elige-alguien = elegí a alguien arriba
ayni-btn-admitir = admitir
ayni-btn-atestar = atestar
ayni-btn-expulsar = expulsar
ayni-btn-acuse = acuse
ayni-label-confianza = confianza (saltos)
ayni-label-sin-atestaciones = — sin atestaciones —
ayni-label-sin-mensajes = — sin mensajes. Escribí abajo (o /ayuda para comandos). —
ayni-compose-placeholder = escribí un mensaje, o /adjuntar <ruta>, /atestar <hex> …
ayni-btn-enviar = enviar
# === chaka-app-llimphi ===
chaka-menu-run = Ejecutar
chaka-menu-run-pipeline = Correr pipeline
chaka-tab-output = Salida
chaka-tab-rust = Rust generado
chaka-tab-diag = Diagnósticos
chaka-btn-run = Correr
chaka-corpus-empty = corpus vacío
chaka-corpus-header = CORPUS
chaka-editor-placeholder = seleccioná un programa del corpus
chaka-no-file = sin archivo
chaka-banner-open-corpus = abrí un programa del corpus a la izquierda
chaka-banner-step-limit = shadow ⚠ se agotó el tope de pasos (¿bucle sin fin?)
chaka-banner-pipeline-error = el pipeline falló — ver tab «Diag» para detalles
chaka-status-no-open-file = no hay archivo abierto para guardar
chaka-about-text = chaka · transpilador COBOL → Rust · pipeline lex→parse→ir→codegen→shadow
# === chasqui-explorer-llimphi ===
chasqui-explorer-ctx-detail = Ver detalle
chasqui-explorer-monad-label = Mónada
chasqui-explorer-monad-stats = { $count } files · ent { $entropy } · { $lens }
# === media-app ===
media-settings-tab-audio = Audio
media-settings-tab-video = Video
media-settings-tab-playback = Reproducción
media-settings-tab-bars = Barras
media-settings-tab-controls = Controles
media-audio-volume = Volumen
media-audio-eq = Ecualizador
media-audio-normalization = Normalización
media-audio-lufs-target = Objetivo LUFS
media-audio-downmix = Downmix estéreo
media-video-color = Color
media-video-enable = Activar
media-video-brightness = Brillo
media-video-contrast = Contraste
media-video-gamma = Gamma
media-video-saturation = Saturación
media-video-hue = Matiz
media-video-orientation = Orientación
media-video-rotation = Rotación
media-video-rotate-cw = rotar 90°
media-video-flip-h = Espejo H
media-video-flip-v = Espejo V
media-action-reset = reset
media-action-cycle = ciclar
media-playback-playlist = Playlist
media-playback-resume = Reanudar al abrir
media-playback-repeat = Repetición
media-playback-shuffle = Aleatorio
media-playback-subtitles = Subtítulos
media-playback-autoload-sidecar = Auto-cargar sidecar
media-playback-sub-delay = Desfase (ms)
media-playback-font-size = Tamaño de letra
media-playback-behavior = Comportamiento
media-playback-crossfade = Crossfade (s)
media-controls-header = Controles (teclado)
media-controls-hint = Editá controles.ron y apretá F5 para reasignar teclas. El editor visual de atajos llega después.
media-bars-header = Barras de controles — clic en un item lo quita
media-bars-bar-label = Barra
media-bars-remove-bar = quitar barra
media-bars-add-bar = + barra nueva
media-bars-add-items-to = Agregar items a:
media-settings-footer = Se guarda en config.ron · Esc cierra · en Barras: clic en un item lo quita, reordenan
media-playlist-header = Lista de reproducción — clic en una pista para saltar
media-playlist-empty = Sin lista de reproducción.
media-win-config-title = Configuración — media
media-win-playlist-title = Lista de reproducción — media
media-help-title = media · atajos
media-help-group-playback = Reproducción
media-help-toggle = Mostrar/ocultar esta ayuda
media-help-close = Cerrar la ayuda
media-help-reload = Recargar controles.ron en caliente
media-menu-capture-frame = Capturar fotograma
media-menu-record = Grabar / detener
media-menu-reload-controls = Recargar controles
media-menu-playback = Reproducción
media-menu-play-pause = Reproducir / pausar
media-menu-seek-back = Retroceder
media-menu-seek-fwd = Avanzar
media-menu-prev-track = Pista anterior
media-menu-next-track = Pista siguiente
media-menu-volume-up = Subir volumen
media-menu-volume-down = Bajar volumen
media-menu-playlist = Lista de reproducción
media-menu-visualizers = Visualizadores de audio
media-menu-shortcuts-help = Ayuda de atajos
media-ctx-stop-record = Detener grabación
media-ctx-record-audio = Grabar audio
# === mirada-app-llimphi ===
mirada-menu-open-window = Abrir ventana
mirada-menu-open-output = Abrir monitor
mirada-menu-close-focused = Cerrar enfocada
mirada-menu-window = Ventana
mirada-win-promote = Promover a maestra
mirada-win-float = Flotar / anclar
mirada-win-fullscreen = Pantalla completa
mirada-win-scratchpad = Enviar al scratchpad
mirada-win-label-fallback = ventana
mirada-layout-cycle = Ciclar layout
mirada-layout-master-stack = Maestro + pila
mirada-layout-monocle = Monóculo
mirada-layout-grid = Rejilla
mirada-layout-columns = Columnas
mirada-layout-rows = Filas
mirada-layout-centered = Maestro centrado
mirada-layout-spiral = Espiral
mirada-layout-shrink = Achicar maestra
mirada-layout-grow = Agrandar maestra
mirada-output-next = Siguiente monitor
mirada-status-body-connected = Cuerpo conectado
mirada-status-simulation = simulación — sin Cuerpo
mirada-status-keymap-reloaded = keymap recargado
mirada-status-keymap-invalid = keymap inválido
mirada-label-layout = layout
mirada-label-focus = foco
mirada-label-output = salida
mirada-label-workspace = escritorio
mirada-canvas-empty-hint = escritorio vacío — pulsa n para abrir una ventana
mirada-win-kind-fullscreen = · pantalla completa ·
mirada-win-kind-floating = · ventana flotante ·
mirada-win-kind-surface = · superficie del Cuerpo ·
# === mirada-greeter ===
mirada-greeter-menu-session = Sesión
mirada-greeter-session-submit = Iniciar sesión
mirada-greeter-session-goto-user = Ir a usuario
mirada-greeter-session-goto-pass = Ir a contraseña
mirada-greeter-label-desktop = Escritorio
mirada-greeter-btn-submit = Entrar
mirada-greeter-btn-submitting = Entrando…
mirada-greeter-hint-nav = ↑/↓: escritorio · Enter: entrar
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: consola · Ctrl+Alt+⌫: salir
# === nakui-explorer-llimphi ===
nakui-explorer-menu-refresh-log = Refrescar log
nakui-explorer-ctx-view-detail = Ver detalle
nakui-explorer-ctx-refresh-log = Refrescar log
nakui-explorer-ctx-entry-fallback = Entrada
# === nakui-sheet-llimphi ===
nakui-sheet-ctx-clear = Limpiar
nakui-sheet-fmt-number = Formato: Número
nakui-sheet-fmt-currency = Formato: Moneda $
nakui-sheet-fmt-percent = Formato: Porcentaje
nakui-sheet-fmt-general = Formato: General
nakui-sheet-freeze-here = Inmovilizar paneles aquí
nakui-sheet-unfreeze = Liberar paneles
nakui-sheet-pivot = Tabla dinámica…
nakui-sheet-menu-cell-cut = Cortar celda
nakui-sheet-menu-cell-copy = Copiar celda
nakui-sheet-menu-cell-paste = Pegar celda
nakui-sheet-menu-cell-clear = Limpiar celda
nakui-sheet-menu-bar-cut = Cortar texto
nakui-sheet-menu-bar-copy = Copiar texto
nakui-sheet-menu-bar-paste = Pegar texto
nakui-sheet-menu-bar-select-all = Seleccionar todo (texto)
nakui-sheet-menu-import-csv = Importar CSV
nakui-sheet-menu-export-csv = Exportar CSV
nakui-sheet-menu-about = Acerca de Nakui Sheet
nakui-sheet-formula-placeholder = ingresa fórmula o valor
nakui-sheet-pivot-title = Tabla dinámica
nakui-sheet-pivot-close = ✕ Esc
nakui-sheet-pivot-group-by = Agrupar por «
nakui-sheet-pivot-over = sobre
nakui-sheet-pivot-with-header = c/encab.
nakui-sheet-pivot-no-header = s/encab.
nakui-sheet-pivot-more-groups = grupos
nakui-sheet-pivot-total = TOTAL
nakui-sheet-pivot-groups = grupos
nakui-sheet-pivot-rows = filas
nakui-sheet-pivot-hint = A función · G grupo · V valor · H encabezado · Esc cerrar
# === paloma-llimphi ===
paloma-status-init = paloma · sin sincronizar
paloma-status-search-semantic = búsqueda semántica (rimay): pendiente — usando exacta
paloma-status-view-rich = HTML enriquecido vía puriy: pendiente (texto despojado por ahora)
paloma-status-no-recipient = no se puede enviar: falta un destinatario válido
paloma-status-sent = enviado
paloma-status-sent-signed = enviado · firmado (Ed25519)
paloma-placeholder-search = Buscar… ( / )
paloma-btn-compose = ✎ Redactar
paloma-nav-calendar = Calendario
paloma-nav-contacts = Contactos
paloma-nav-soon = pronto
paloma-empty-threads = Bandeja vacía
paloma-empty-search = sin coincidencias
paloma-search-exact = Exacta
paloma-search-semantic = Semántica
paloma-no-subject = (sin asunto)
paloma-placeholder-read = Elegí un hilo para leerlo
paloma-btn-reply = Responder
paloma-btn-forward = Reenviar
paloma-btn-star = Destacar
paloma-btn-starred = Destacado
paloma-btn-mark-unread = Marcar no leído
paloma-btn-mark-read = Marcar leído
paloma-btn-view-rich = Ver HTML enriquecido
paloma-msg-to-label = para
paloma-sig-verified = firmado
paloma-sig-invalid = firma inválida
paloma-compose-new = Mensaje nuevo
paloma-compose-reply-title = Responder
paloma-compose-placeholder-to = Para: nombre <correo@dominio>
paloma-compose-placeholder-cc = Cc: (opcional)
paloma-compose-placeholder-subject = Asunto
paloma-compose-placeholder-body = Escribí tu mensaje…
paloma-compose-sign = Firmar (Ed25519)
paloma-compose-send = Enviar
# === pluma-notebook-llimphi ===
pluma-notebook-fit-all = Ajustar todo
pluma-notebook-center = Centrar
pluma-notebook-zoom-reset = Zoom 100%
# === raymi-llimphi ===
raymi-tab-calendar = Calendario
raymi-tab-contacts = Contactos
raymi-view-month = Mes
raymi-view-week = Semana
raymi-view-day = Día
raymi-btn-new-event = Evento
raymi-btn-today = Hoy
raymi-btn-new-contact = Contacto
raymi-no-events = sin eventos
raymi-all-day = todo el día
raymi-no-contacts = sin contactos
raymi-search-contact-placeholder = 🔍 Buscar contacto…
raymi-select-contact-hint = Elegí un contacto
raymi-title-edit-event = Editar evento
raymi-title-new-event = Nuevo evento
raymi-title-edit-contact = Editar contacto
raymi-title-new-contact = Nuevo contacto
raymi-change-cycle = cambiar
raymi-field-summary = Asunto
raymi-field-all-day = Día completo
raymi-field-apply-to = Aplicar a
raymi-field-calendar = Calendario
raymi-field-date = Fecha
raymi-field-start = Inicio
raymi-field-end = Fin
raymi-field-location = Lugar
raymi-ph-location = Lugar (opcional)
raymi-field-description = Descripción
raymi-ph-description = Notas (opcional)
raymi-field-attendees = Invitados
raymi-ph-invitee = Nombre <correo> · Enter
raymi-field-repeat = Repetir
raymi-field-every = Cada
raymi-field-days = Días
raymi-field-ends = Termina
raymi-field-name = Nombre
raymi-field-emails = Correos
raymi-field-phones = Teléfonos
raymi-field-org = Organización
raymi-field-note = Nota
raymi-ph-full-name = Nombre y apellido
raymi-ph-emails = correo@dominio, otro@…
raymi-ph-phones = +58 412…, …
raymi-ph-org = Empresa (opcional)
raymi-ph-note = Nota (opcional)
raymi-scope-series = Toda la serie
raymi-scope-this-only = Esta instancia
raymi-scope-this-and-future = Esta y siguientes
raymi-repeat-none = No se repite
raymi-repeat-daily = Diariamente
raymi-repeat-weekly = Semanalmente
raymi-repeat-monthly = Mensualmente
raymi-repeat-yearly = Anualmente
raymi-unit-days = día(s)
raymi-unit-weeks = semana(s)
raymi-unit-months = mes(es)
raymi-unit-years = año(s)
raymi-end-never = Sin fin
raymi-end-count = Tras N veces
raymi-end-until = Hasta fecha
raymi-status-no-calendars = no hay calendarios donde crear un evento
raymi-status-no-books = no hay libretas donde crear un contacto
raymi-status-invalid-datetime = fecha u hora inválida (usá AAAA-MM-DD y HH:MM)
raymi-status-contact-needs-name = el contacto necesita un nombre
# === shuma-shell-llimphi ===
shuma-shell-clear-input = Limpiar entrada
shuma-shell-clear-screen = Limpiar pantalla
shuma-shell-cancel-cmd = Cancelar comando
shuma-shell-about = Acerca de shuma
# === supay-app-llimphi ===
supay-hud-health = VIDA
supay-hud-ammo = MUNICION
supay-hud-target = OBJETIVO
supay-action-fire = Disparar
supay-action-reset = Reiniciar partida
supay-menu-play = Jugar
supay-status-game-over = fin de partida
supay-status-victory = victoria
supay-status-dead = MUERTO
supay-hint-space-restart = SPACE para reiniciar
# === wawa-panel-llimphi ===
wawa-panel-status-config-updated = ↻ config actualizada desde el bus
wawa-panel-ctx-refresh-monitor = Refrescar monitor
wawa-panel-autosave-ok = ↻ aplicado
+724
View File
@@ -0,0 +1,724 @@
# rimay-localize — qu-PE (Runasimi, variante sureña).
#
# Nota para el revisor humano: este catálogo es un PUNTO DE PARTIDA
# escrito por un desarrollador no nativo. Las formas elegidas siguen
# fuentes accesibles (AMLQ, Cusqueño escrito) pero piden corrección por
# alguien con dominio del idioma. Pluralización (sufijo -kuna), ergativo
# (-pa, -wan) y cortesía (-yki) son las áreas más sensibles.
# === acciones genéricas ===
save = Waqaychay
load = Apamuy
open = Kichay
close = Wisq'ay
cancel = Saqiy
confirm = Allinmi
yes = Arí
no = Manan
delete = Pichay
edit = Hukchay
new = Musuq
# === estado ===
play = Qallariy
pause = Samay
resume = Kutiy
stop = Sayachiy
# === menús ===
file = Qillqa
view = Qhaway
help = Yanapay
settings = Allichana
exit = Lluqsiy
# === chrome común (reutilizable; PUNTO DE PARTIDA, pide revisión nativa) ===
search = Maskay
language = Rimay
undo = Kutichiy
redo = Kutirichiy
cut = Kuchuy
copy = Iskaychay
paste = Llut'ay
select-all = Llapanta akllay
open-dots = Kichay…
save-as = Wak hina waqaychay…
close-tab = Wisq'ay
find-in-file = Qillqapi maskay
find-in-project = Llapanpi maskay
symbols = Unanchakuna
goto-definition = Sut'ipi riy
terminal = Terminal
command-palette = Kamachiykuna
minimap = Huch'uy mapa
cycle-theme = Rikch'aq tikray
editing = Hukchana
about = Imamanta
refresh = Musuqyachiy
reconnect = Kutillamanta tinkiy
# === nada (qillqa llamk'ana) ===
nada-tagline = Llimphi patapi kamachiq qillqaq
# === niveles de mensaje ===
info = Willay
warning = Yuyaymanay
error = Pantay
success = Allinmi
# === interpolación ===
welcome-user = Allin hamusqaykim, { $name }.
items-count = { $count } imaymana.
# === dominium (chawpi pachapi pukllachiq) ===
dominium-status-running = ● purichkan
dominium-status-paused = ‖ samachkan
dominium-status-line = dominium · chawpi pacha · wiñay { $epoch } · thaski { $tick }
dominium-btn-pause = ‖ Samay
dominium-btn-resume = ▶ Kutiy
dominium-btn-reseed = ↺ Watiq taqraay
dominium-btn-create-concept = ✦ Yuyay ruway
dominium-btn-seed-pack = ✚ Taqra churay
dominium-btn-clear = ✖ Pichay
dominium-btn-save = 💾 Waqaychay
dominium-btn-load-saved = 📂 Waqaychasqa apamuy
dominium-btn-load-named = ✓ «{ $name }» apamuy
dominium-header-sim = [ PUKLLAY ]
dominium-header-conceptos = [ YUYAYKUNA ]
dominium-header-metricas = [ TUPUCHIQKUNA ]
dominium-header-editar = [ HUKCHAY ]
dominium-active-count = { $count } kawsachkan
dominium-stat-population = Runa hunt'ay
dominium-stat-materia = Materia
dominium-stat-oro = Quri
dominium-stat-energia = Kallpa
dominium-stat-epoca = Wiñay
dominium-stat-gini-energia = Gini kallpa
dominium-stat-edad-media = Watayuq chawpi
dominium-stat-var-psi-orden = Var ψ kamachiy
dominium-stat-var-psi-miedo = Var ψ manchakuy
dominium-stat-var-psi-curiosidad = Var ψ tapukuy
dominium-stat-var-psi-corruptib = Var ψ ismuriy
dominium-action-mover = → kuyuy
dominium-action-extraer = → hurquy
dominium-action-sincronizar = → tinkichiy
dominium-action-intercambiar = → chhalaway
dominium-action-replicar = → kikinchay
dominium-action-degradar = → uray
dominium-slider-nombre = suti
dominium-slider-radius = mukmu
dominium-slider-materia = materia
dominium-slider-psique = nuna
dominium-slider-poder = atiy
dominium-slider-oro = quri
dominium-label-hack = hack:
# === cosmos (overlay módulos) ===
cosmos-btn-save-transit = 💾 Purichiqta qispi qillqaman waqaychay
cosmos-btn-save-progressed = 💾 Wiñasqata qispi qillqaman waqaychay
cosmos-btn-save-return = 💾 Kutiqta qispi qillqaman waqaychay
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
cosmos-demo-title = Qhawanapaq qillqa (Lima)
cosmos-demo-subtitle = cosmos-engine yupan (VSOP2013)
cosmos-status = { $ms } ms · { $layers } qatakuna · { $overlays } overlays · { $aspects } aspectos
cosmos-status-error = pantasqa: { $err }
cosmos-overlay-transit = puriq
cosmos-overlay-progression = wiñay
cosmos-overlay-solar-arc = inti arco
cosmos-overlay-uranian = uraniano
cosmos-overlay-lots = lote
cosmos-overlay-fixed-stars = qulluy
cosmos-overlay-midpoints = chawpi
cosmos-harmonic-label = armónico
cosmos-empty = (manaña)
cosmos-tile-carta = qillqa
cosmos-tile-modulos = módulos
cosmos-tile-armonico = armónico
cosmos-tile-cuerpos = ukhukuna
cosmos-tile-aspectos = aspectos
cosmos-tile-box-graph = aspectarian
cosmos-tile-cualidades = sayaykuna
cosmos-elementos = elementos
cosmos-modalidades = modalidades
cosmos-polaridad = iskaynin
cosmos-elem-fuego = nina
cosmos-elem-tierra = allpa
cosmos-elem-aire = wayra
cosmos-elem-agua = unu
cosmos-mod-cardinal = cardinal
cosmos-mod-fijo = thatkiqnin
cosmos-mod-mutable = mutable
cosmos-pol-yang = yang
cosmos-pol-yin = yin
cosmos-tile-astrocarto = astrocartografía
cosmos-astrocarto-leyenda = MC q'ipi · IC chiqan · Asc/Desc qinqu · • paqarisqa
cosmos-tile-cartas = waqaychasqa qillqakuna
cosmos-cartas-duplicar = + kunan iskayachay
cosmos-cartas-vacio = (chusaq — kunanta iskayachay icha JSON-kunata churay)
cosmos-tile-corpus = corpus
cosmos-tile-lotes = lote
cosmos-tile-estrellas-fijas = qulluy
cosmos-tile-puntos-medios = chawpi
cosmos-corpus-header = { $pasajes } pasajes · { $huecos } pisi · { $total } tinkunakuna
cosmos-corpus-vacio = (mana pasajes — cosmos-corpus/ejemplo.ron qillqay)
cosmos-tile-uraniano = uraniano 90° muyu
cosmos-tile-cross-transit = cross · puriq
cosmos-tile-cross-progression = cross · wiñay
cosmos-tile-cross-solar-arc = cross · inti arco
# === wawa-explorer (Wawa imagen qhawana) ===
wawa-marker-via-aoe = · AoE-pi
wawa-marker-searching = · maskachkan…
wawa-marker-fetch-failed = · fetch pantasqa
wawa-marker-not-in-image = · (mana imagenpi)
wawa-iface-ok = · AoE iface: { $name }
wawa-iface-err = · AoE: mana interfaz
wawa-header-error = wawa-explorer · pantay: { $err }
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } imaymana{ $iface }
wawa-detail-empty = (huk imaymanata akllariy tree-pi)
wawa-detail-title = imaymana { $hash } · { $bytes } bytes · { $children } wawa{ $origen }
wawa-detail-title-missing = imaymana { $hash } · mana kaypi
wawa-detail-payload-header = payload (ñawpaq 256 bytes):
wawa-detail-children-header = wawakuna:
wawa-detail-child-missing = (mana imagenpi)
wawa-detail-searching-aoe-1 = local red AoE-pi maskachkan…
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, suyay ProveedorObjeto verified hash-niyuq.
wawa-detail-fetch-error-1 = AoE intento pantasqa:
wawa-detail-fetch-error-2 = kay botón qhipata watiq maskayta atinki.
wawa-detail-needs-fetch-1 = kay imaymana huk tayta-pi nisqa, ichaqa mana local imagen-pi kawsachkan.
wawa-detail-needs-fetch-2 = local red Wawa peer-kuna-mantapis mañakuyta atinki (AoE, iface `{ $iface }`).
wawa-detail-aoe-disabled-1 = kay imaymana huk tayta-pi nisqa, ichaqa mana local imagen-pi kawsachkan.
wawa-detail-aoe-disabled-2 = AoE wisq'asqa: { $why }
wawa-detail-aoe-disabled-3 = CLI iskaynin parlachi-pi `<iface>` churay icha CAP_NET_RAW-wan kachay (`sudo setcap cap_net_raw=eip <binario>`).
wawa-btn-fetch = peer-kuna-manta apamuy
wawa-btn-retry-fetch = peer-kuna-manta watiq apamuy
# hatun menú
wawa-menu-file = Khipu
wawa-menu-reload = Mosoqmanta kichay
wawa-menu-quit = Lloqsiy
wawa-menu-view = Qhaway
wawa-menu-fetch = AoE-wan nodo apamuy
wawa-menu-theme = Tema tikray
wawa-menu-help = Yanapay
wawa-menu-about = Paymanta
# akllasqa nodopi contextual menú
wawa-ctx-select = Akllay
wawa-ctx-expand = Kichariy
wawa-ctx-collapse = Wisqay
wawa-ctx-fetch = AoE-wan apamuy
# === minga-explorer (repo qhawana) ===
minga-header-loaded = Repo: { $path } · watiq apamuy { $ms } ms
minga-header-searching = { $path }-pi repo maskachkan…
minga-error-read = { $path } repo mana ñawinchayta atinichu: { $err }
minga-card-nodes-title = AST Yuyay
minga-card-nodes-desc = código-manta parsesqa fragments
minga-card-attestations-title = Firmasqakuna
minga-card-attestations-desc = nodos hawapi Ed25519 firmakuna
minga-card-mst-title = MST Llaves
minga-card-mst-desc = Merkle Search Tree-pi yaykuq
minga-empty = Ñawpaq refresh suyachkan…
minga-menu-file = Willay
minga-menu-view = Qhaway
minga-menu-help = Yanapay
minga-menu-refresh = Musuqchay
minga-menu-quit = Lluqsiy
minga-menu-theme = Tema tikray
minga-menu-about = Paymanta
minga-menu-context-title = Repo
# === nakui-explorer (event log) ===
nakui-explorer-header = Log: { $path } · { $entries } yaykuq ({ $seeds } seeds, { $morphisms } morphisms) · watiq apamuy { $ms } ms
nakui-explorer-breakdown = rakiy: { $parts }
# === supay (doom) ===
supay-mode-real = MOTOR PAQARIQ
supay-mode-stub = STUB
supay-view-fb = qhaway=FB (F3→3D)
supay-view-3d = qhaway=3D (F3→FB)
supay-header = { $title } · thaski { $tick } · { $mode } · { $view } · { $scene }
supay-stub-title = supay-doom-llimphi STUB modo-pi purichkan
supay-stub-step-1 = doomgeneric-ta apamuy
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
supay-stub-step-2 = WAD shareware-ta cwd-man apamuy
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
supay-stub-step-3 = Watiq kachay
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
supay-stub-footer = doomgeneric (C) 35 Hz-pi puriy; framebuffer 320×200 ARGB aspect-fit-wan llimpisqa.
supay-controls-hint = WASD · Ctrl tuksiy · Space kichay · Tab mapa · F3 qhaway · F4 ñawi · F5 llantu · F6 HUD · F7 ll-chaki · F8 q'ancha · F9 hark'ay · F10 mobj-k'anchay · F11 maki-k'anchay · F12 lluqsiy
supay-stub-controls-hint = F3 FB/3D tikray · F12 wisq'ay
# === shuma-shell ===
shuma-label-launcher = Launcher
shuma-label-command = Kamachiq
shuma-label-shell = Shell
shuma-label-matilda = Matilda
shuma-label-canvas = Rikukuy
shuma-label-monitors = Qhawaqkuna
shuma-empty-main-incompat = Main yanapakuq mana atinmanchu
shuma-empty-no-tabs = Mana tabs churaqa.
shuma-empty-no-tabs-compat = Kay yanapakuq mana tab kayta atinmanchu.
shuma-empty-no-data-linux = mana willay (¿manachu Linux?)
shuma-empty-no-data = mana willay
shuma-stat-samples = qhawasqakuna: { $have } / { $total }
# === nahual (qhawanakuna) ===
nahual-image-unsupported = mana atisqa formato (kay build-pi PNG/JPEG sapanlla)
# === greeter (mirada login) ===
greeter-subtitle = sesionniykita qallariy
greeter-label-user = sutiyki
greeter-label-password = pakasqa rimay
greeter-placeholder-user = sutiykita churay
greeter-status-authenticating = qhawachkani…
greeter-error-empty-user = sutiyki churay
# === nakui (ERP shell) ===
nakui-header = Nakui · { $count } yanapakuq
nakui-sidebar-modules = Yanapakuqkuna ({ $count })
nakui-sidebar-menu = Akllana
nakui-empty-no-modules = Mana yanapakuq apamusqa
nakui-empty-pick-menu = Akllanata akllariy lateral barrapi
nakui-empty-pick-module = Yanapakuqta akllariy lateral barrapi
nakui-pending-edit = hukchay suyaykuchkan: meta-form Llimphi munakun
nakui-pending-render-detail = qhawachiy suyaykuchkan: meta-form Llimphi munakun
nakui-pending-render-dashboard = qhawachiy suyaykuchkan: dashboard Llimphi munakun
# === pluma (DAG hukchaq) ===
pluma-tone-valid = khuska
pluma-tone-pending = qhawana
pluma-tone-conflict = ch'aqwaypi
# === gioser-edit (qillqa hukchaq) ===
edit-status-find = maskay · Ctrl+G qatiq · Esc wisq'ay
edit-status-goto-def-waiting = goto-def · LSP suyaykuchkan…
edit-status-references-waiting = references · LSP suyaykuchkan…
edit-status-rename-input = sutichay · Enter ruway · Esc saqiy
edit-status-rename-waiting = sutichay → «{ $name }» · LSP suyaykuchkan…
edit-status-rename-error = sutichay · pantay { $path }: { $err }
edit-status-rename-done = sutichay · { $files } qillqa · { $bytes } bytes
edit-status-formatting-waiting = patachay · LSP suyaykuchkan…
edit-status-formatting-done = patachay · churasqa
edit-status-goto-def-at = goto-def · { $path }:{ $line }
edit-status-goto-def-error = goto-def · pantay kichaspa { $path }: { $err }
edit-status-saved = waqaychasqa · { $path }
edit-status-save-error = pantay waqaychaspa: { $err }
edit-header-hint = Ctrl+Shift+P akllana · Ctrl+P qillqakuna · Ctrl+Shift+F maskay
edit-status-position = Ln { $line }, Col { $col } · { $lang }
# === chasqui-explorer (mónadas) ===
chasqui-header = Engine '{ $engine }' · { $count } mónada · socket: { $socket } ({ $src }){ $watching }
chasqui-header-watching = · qhawachkan: { $name }
chasqui-header-searching = Chasqui daemonta maskaspa brahman-brokerwan…
chasqui-field-id = id: { $id }
chasqui-field-watching = qhawachkan: { $name }
chasqui-field-keywords = rimaykuna: { $keywords }
chasqui-field-path = ñan: { $path }
chasqui-field-model = modelo: { $name }
# === wawa-panel (wawa SO panilninkuna) ===
wawa-panel-title = wawa · kamachiy panil
wawa-panel-cat-appearance = Rikch'aynin
wawa-panel-cat-language = Simi
wawa-panel-cat-apps = Llamk'anakuna
wawa-panel-cat-monitor = Qhaway
wawa-panel-cat-modules = T'aqakuna
wawa-panel-cat-about = Imaynan
wawa-panel-section-appearance-hint = Llinphi rikch'ay, tinki ima.
wawa-panel-section-language-hint = Sistemaq simin, pacha rikch'ay ima.
wawa-panel-section-apps-hint = wawaq llamk'ananta kichay.
wawa-panel-section-monitor-hint = Kunan sistemaq munay kawsaynin.
wawa-panel-section-modules-hint = SOq t'aqankunata churay otaq qichuy.
wawa-panel-section-about-hint = Sistemaq willaynin.
wawa-panel-label-variant = Rikch'ay
wawa-panel-label-accent = Tinki
wawa-panel-label-language = Simi
wawa-panel-label-clock = Pacha
wawa-panel-variant-dark = Llanthu
wawa-panel-variant-light = K'anchay
wawa-panel-variant-aurora = Aurora
wawa-panel-variant-sunset = Inti haykuy
wawa-panel-clock-24h = 24 h
wawa-panel-clock-12h = 12 h
wawa-panel-stat-time = Pacha
wawa-panel-stat-uptime = Sayasqa pacha
wawa-panel-stat-mem = Yuyay
wawa-panel-stat-load = Q'ipi
wawa-panel-stat-host = Wasiq
wawa-panel-stat-kernel = Sunqu
wawa-panel-action-launch = Kichay
wawa-panel-action-save = Waqaychay
wawa-panel-action-reset = Kaqmanta churay
wawa-panel-saved = Kamachiy waqaychasqa { $path } nisqapi
wawa-panel-reset = Kamachiy kaqmanta churasqa
wawa-panel-menu-file = Willay
wawa-panel-menu-view = Qhaway
wawa-panel-menu-help = Yanapay
wawa-panel-menu-quit = Lluqsiy
wawa-panel-status-hint = ↑↓ puriy · Enter ruway · Ctrl+S waqaychay · Esc lluqsiy
wawa-panel-about-name = Sistema
wawa-panel-about-version = Mit'a
wawa-panel-about-kernel = Sunqu
wawa-panel-about-toolkit = Llamk'ana qillqana
wawa-panel-about-blurb = wawa kaqmi gioser suiteq sistemaynin. arje sunqu, llimphi llamk'anakuna, huch'uy userland patapi.
wawa-panel-mod-mirada = mirada · wayland kamachiq
wawa-panel-mod-shuma = shuma · q'ipichay, willay ima
wawa-panel-mod-chasqui = chasqui · willasqa, chasqui ima
wawa-panel-mod-akasha = akasha · musuqyachiy ñan
wawa-panel-mod-minga = minga · p2p waqaychana
wawa-panel-mod-agora = agora · llaqta plaza
wawa-panel-mod-on = kasqa
wawa-panel-mod-off = wañusqa
# === mirada-asistente ===
# Llimphi rurana, runa siminchikmanta `mirada-ctl` kamachikunaman tikrachiq,
# LLM-ta tapuspa. Nahual nin, runa hunisqa ruwakun.
asistente-title = carmen · yanapaq
asistente-sub = imatachus ruway munanki nipuway; yanapaq nin, qan hunichinki.
asistente-placeholder = imatachus ruwayta munanki? (Enter tapunapaq, Esc pichanapaq)
asistente-banner-no-llm = LLM mana kanchu: { $motivo }
asistente-status-pensando = yuyaykuspa…
asistente-boton-ejecutar = Ruway
asistente-boton-descartar = Wikch'uy
asistente-ejecutado-ok = ✓ { $accion } ruwasqa
asistente-ejecutado-fallo = ✗ { $accion } pantapun
asistente-error-transporte = apaqniyoq: { $motivo }
asistente-error-sin-llm = LLM mana kallarisqachu
asistente-error-sin-json = JSON mana kapuq kutichiq: { $crudo }
asistente-error-accion-vacia = ruwana mana kanchu chay nisqaqa: { $crudo }
asistente-error-json-invalido = JSON mana riqsisqa: { $crudo }
asistente-error-spawn = paqarichiy pantapun: { $err } (mirada-ctl PATH-pi kachkanchu?)
asistente-cero-salida = (mana kutichiq)
asistente-codigo-salida = lluqsiy yupay { $codigo }
asistente-error-accion-desconocida = LLM mana riqsisqa ruwanata nirqa: { $accion }
# === ayni-llimphi ===
ayni-menu-admitir = Chaskiy aklasqata
ayni-menu-atestar = Reqsichiy aklasqata
ayni-menu-expulsar = Qharquy aklasqata
ayni-menu-enviar-msg = Willayta kachamuy
ayni-menu-adjuntar = Huñichiy willakuyta…
ayni-menu-acuse = Chaskisqata willakuy
ayni-menu-cifrado = Pakasqa kachana E2EE
ayni-menu-recibos = Leeqsisqamanta willakuykuna
ayni-menu-comandos-barra = / barramanta kamachikuna
ayni-label-gente-miembros = RUNAKUNA — ayllukuna
ayni-label-otros-vistos = wakinkuna rikusqakuna
ayni-label-acciones = ruraykuna
ayni-label-elige-alguien = huk runata akllay
ayni-btn-admitir = chaskiy
ayni-btn-atestar = reqsichiy
ayni-btn-expulsar = qharquy
ayni-btn-acuse = chaskisqa
ayni-label-confianza = confiyay (brincaykuna)
ayni-label-sin-atestaciones = — mana reqsichisqachu —
ayni-label-sin-mensajes = — mana willakuykunachu. Uray qillqay (o /ayuda kamachikuymanta). —
ayni-compose-placeholder = willakuyta qillqay, o /adjuntar <ñan>, /atestar <hex> …
ayni-btn-enviar = kachamuy
# === chaka-app-llimphi ===
chaka-menu-run = Ruway
chaka-menu-run-pipeline = Pipeline ruwarina
chaka-tab-output = Lluqsiy
chaka-tab-rust = Rust ruwasqa
chaka-tab-diag = Taripanakuy
chaka-btn-run = Ruway
chaka-corpus-empty = corpus ch'uwa
chaka-corpus-header = CORPUS
chaka-editor-placeholder = corpus nisqamanta huk programata akllay
chaka-no-file = mana archivoyuq
chaka-banner-open-corpus = corpus nisqamanta huk programata lloq'e ladopi kichay
chaka-banner-step-limit = shadow ⚠ hatun takiy tukurirqan (¿tukukunaymanchu muyuriy?)
chaka-banner-pipeline-error = pipeline pantarqan — «Diag» pestaña-pi qhawaychis
chaka-status-no-open-file = mana kicharisqa archivom kan waqaychananpaq
chaka-about-text = chaka · COBOL → Rust tikrasqa · pipeline lex→parse→ir→codegen→shadow
# === chasqui-explorer-llimphi ===
chasqui-explorer-ctx-detail = Qhawarisqa
chasqui-explorer-monad-label = Mónada
chasqui-explorer-monad-stats = { $count } archivos · ent { $entropy } · { $lens }
# === media-app ===
media-settings-tab-audio = Uyariy
media-settings-tab-video = Rikuriy
media-settings-tab-playback = Qhawana
media-settings-tab-bars = Wara
media-settings-tab-controls = Kamachikuy
media-audio-volume = K'aqay
media-audio-eq = Tupuy
media-audio-normalization = Allichakuy
media-audio-lufs-target = LUFS yupay
media-audio-downmix = Iskay qhipa
media-video-color = Riqsinchay
media-video-enable = Qallariy
media-video-brightness = K'anchay
media-video-contrast = Tupay
media-video-gamma = Gamma
media-video-saturation = Hunt'ay
media-video-hue = Killayay
media-video-orientation = Tupuy
media-video-rotation = Muytuy
media-video-rotate-cw = muytuy 90°
media-video-flip-h = Lluqsinchay
media-video-flip-v = Hawaniy
media-action-reset = qallariy
media-action-cycle = muyuy
media-playback-playlist = Takiy lista
media-playback-resume = Kutimuy
media-playback-repeat = Kutichiy
media-playback-shuffle = Ch'aqnay
media-playback-subtitles = Qillqa
media-playback-autoload-sidecar = Kikin maskay
media-playback-sub-delay = Atiy (ms)
media-playback-font-size = Qillqa hatun
media-playback-behavior = Ruway
media-playback-crossfade = Tikray (s)
media-controls-header = Kamachikuy (teclado)
media-controls-hint = controles.ron t'aqay F5 apakuy kachiy.
media-bars-header = Kamachikuy warakuna — clic quichuy
media-bars-bar-label = Wara
media-bars-remove-bar = wara quichuy
media-bars-add-bar = + wara musuq
media-bars-add-items-to = Yaykuchiy:
media-settings-footer = config.ron waqaychakun · Esc wichukuy
media-playlist-header = Takiy lista — clic brinkay
media-playlist-empty = Mana lista kanchu.
media-win-config-title = Churay — media
media-win-playlist-title = Takiy lista — media
media-help-title = media · iskay-kachiy
media-help-group-playback = Qhawana
media-help-toggle = Rikuchiy/pakay yanapay
media-help-close = Wichukuy yanapay
media-help-reload = controles.ron kutimuy
media-menu-capture-frame = Qhawana aysay
media-menu-record = Qillqay / sayay
media-menu-reload-controls = Kamachikuy kutimuy
media-menu-playback = Qhawana
media-menu-play-pause = Qhaway / sayay
media-menu-seek-back = Qipa kutiy
media-menu-seek-fwd = Ñaupay
media-menu-prev-track = Qipa takiy
media-menu-next-track = Ñaupa takiy
media-menu-volume-up = K'aqay huqariy
media-menu-volume-down = K'aqay urmachiy
media-menu-playlist = Takiy lista
media-menu-visualizers = Uyariy rikuchiy
media-menu-shortcuts-help = Iskay-kachiy yanapay
media-ctx-stop-record = Qillqay sayay
media-ctx-record-audio = Uyariy qillqay
# === mirada-app-llimphi ===
mirada-menu-open-window = Tukuypaq qichariña
mirada-menu-open-output = Qhawana qichariña
mirada-menu-close-focused = Qhawasqa wichʼuña
mirada-menu-window = Tukuy
mirada-win-promote = Mama kamachiqman apariña
mirada-win-float = Phawariña / sayachiña
mirada-win-fullscreen = Llapan pantalla
mirada-win-scratchpad = Scratchpadman kachariña
mirada-win-label-fallback = tukuy
mirada-layout-cycle = Churasqata muyuriña
mirada-layout-master-stack = Mama + sarqay
mirada-layout-monocle = Huk ñawi
mirada-layout-grid = Chʼuqicha
mirada-layout-columns = Sayariykuna
mirada-layout-rows = Silaykuna
mirada-layout-centered = Chawpipi mama
mirada-layout-spiral = Muyuq
mirada-layout-shrink = Mamata huchuychariña
mirada-layout-grow = Mamata hatunychariña
mirada-output-next = Qatiq qhawana
mirada-status-body-connected = Cuerpo huñisqa
mirada-status-simulation = simulación — Cuerpo illaq
mirada-status-keymap-reloaded = keymap kutichisqa
mirada-status-keymap-invalid = keymap mana allin
mirada-label-layout = churasqa
mirada-label-focus = qhawasqa
mirada-label-output = lluqsiy
mirada-label-workspace = llamkana
mirada-canvas-empty-hint = llamkana ch'in — n nispa hatarichiña
mirada-win-kind-fullscreen = · llapan pantalla ·
mirada-win-kind-floating = · phawaq tukuy ·
mirada-win-kind-surface = · Cuerpop sawanpi ·
# === mirada-greeter ===
mirada-greeter-menu-session = Yaykuy
mirada-greeter-session-submit = Yaykuy qallariy
mirada-greeter-session-goto-user = Sutiyman ri
mirada-greeter-session-goto-pass = Pasakllavaman ri
mirada-greeter-label-desktop = Llamk'ana
mirada-greeter-btn-submit = Yaykuy
mirada-greeter-btn-submitting = Yaykuchkaspa…
mirada-greeter-hint-nav = ↑/↓: llamk'ana · Enter: yaykuy
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: consola · Ctrl+Alt+⌫: lluqsiy
# === nakui-explorer-llimphi ===
nakui-explorer-menu-refresh-log = Quillqa musuqyachiy
nakui-explorer-ctx-view-detail = Qhaway chikan
nakui-explorer-ctx-refresh-log = Quillqa musuqyachiy
nakui-explorer-ctx-entry-fallback = Yaykuy
# === nakui-sheet-llimphi ===
nakui-sheet-ctx-clear = Chuyayachiy
nakui-sheet-fmt-number = Formato: Yupay
nakui-sheet-fmt-currency = Formato: Qullqi $
nakui-sheet-fmt-percent = Formato: Chunka-chunka
nakui-sheet-fmt-general = Formato: Sapsa
nakui-sheet-freeze-here = Kaypi suyukunata siriychiy
nakui-sheet-unfreeze = Suyukunata kacharpay
nakui-sheet-pivot = Tukuchiy mesa…
nakui-sheet-menu-cell-cut = Celdata kuchuy
nakui-sheet-menu-cell-copy = Celdata rurayta
nakui-sheet-menu-cell-paste = Celdata churay
nakui-sheet-menu-cell-clear = Celdata chuyayachiy
nakui-sheet-menu-bar-cut = Simiyta kuchuy
nakui-sheet-menu-bar-copy = Simiyta rurayta
nakui-sheet-menu-bar-paste = Simiyta churay
nakui-sheet-menu-bar-select-all = Llapan simiyta akllay
nakui-sheet-menu-import-csv = CSV hayquchiy
nakui-sheet-menu-export-csv = CSV lluqsichiy
nakui-sheet-menu-about = Nakui Sheet haqay
nakui-sheet-formula-placeholder = fórmula utaq valorniyuqta qillqay
nakui-sheet-pivot-title = Tukuchiy mesa
nakui-sheet-pivot-close = ✕ Esc
nakui-sheet-pivot-group-by = Huñuy «
nakui-sheet-pivot-over = patapi
nakui-sheet-pivot-with-header = umayuq
nakui-sheet-pivot-no-header = umachiy
nakui-sheet-pivot-more-groups = huñuykuna
nakui-sheet-pivot-total = TUKUY
nakui-sheet-pivot-groups = huñuykuna
nakui-sheet-pivot-rows = saytuqkuna
nakui-sheet-pivot-hint = A ruwana · G huñuy · V chanin · H uma · Esc wisq'ay
# === paloma-llimphi ===
paloma-status-init = paloma · mana tupachisqa
paloma-status-search-semantic = rimay maskay: suyasqam — chiqan maskaywan
paloma-status-view-rich = puriy HTML: suyasqam (kunanmanta llamp'u qillqa)
paloma-status-no-recipient = mana kachayta atikuchu: mana allin chaskiqkuna
paloma-status-sent = kachasqa
paloma-status-sent-signed = kachasqa · firmayuq (Ed25519)
paloma-placeholder-search = Maskay… ( / )
paloma-btn-compose = ✎ Qillqay
paloma-nav-calendar = Killaka
paloma-nav-contacts = Riqsisqakuna
paloma-nav-soon = utqaylla
paloma-empty-threads = Ch'usaq chhawa
paloma-empty-search = mana tupanakuychu
paloma-search-exact = Chiqan
paloma-search-semantic = Rimay ukhunpi
paloma-no-subject = (mana asuntuyuq)
paloma-placeholder-read = Uj hilota akllay leenampaq
paloma-btn-reply = Kutichiy
paloma-btn-forward = Watiqmanta kachay
paloma-btn-star = Ch'askayman churay
paloma-btn-starred = Ch'askayuq
paloma-btn-mark-unread = Mana leesqaña nispa ch'aniyay
paloma-btn-mark-read = Leesqaña nispa ch'aniyay
paloma-btn-view-rich = HTML sumaqta qhaway
paloma-msg-to-label = riqsichiq
paloma-sig-verified = firmayuq
paloma-sig-invalid = mana allin firma
paloma-compose-new = Mosoj qillqa
paloma-compose-reply-title = Kutichiy
paloma-compose-placeholder-to = Riqsichiq: suti <correo@dominio>
paloma-compose-placeholder-cc = Cc: (munasqayki)
paloma-compose-placeholder-subject = Asunto
paloma-compose-placeholder-body = Qillqay willakuyniquita…
paloma-compose-sign = Firmay (Ed25519)
paloma-compose-send = Kachay
# === pluma-notebook-llimphi ===
pluma-notebook-fit-all = Llapallan tupuchiy
pluma-notebook-center = Chawpiman apaykachay
pluma-notebook-zoom-reset = Zoom 100%
# === raymi-llimphi ===
raymi-tab-calendar = Killay qillqa
raymi-tab-contacts = Rimasqakuna
raymi-view-month = Killa
raymi-view-week = Saqra
raymi-view-day = Punchaw
raymi-btn-new-event = Kausay
raymi-btn-today = Kunan punchaw
raymi-btn-new-contact = Rimasqa
raymi-no-events = mana kausaykunachu
raymi-all-day = punchaw tukuy
raymi-no-contacts = mana rimasqachu
raymi-search-contact-placeholder = 🔍 Rimasqa maskhay…
raymi-select-contact-hint = Rimasqa akllay
raymi-title-edit-event = Kausay tikray
raymi-title-new-event = Musuq kausay
raymi-title-edit-contact = Rimasqa tikray
raymi-title-new-contact = Musuq rimasqa
raymi-change-cycle = tikray
raymi-field-summary = Muhu
raymi-field-all-day = Punchaw tukuy
raymi-field-apply-to = Riqsichiy
raymi-field-calendar = Killay qillqa
raymi-field-date = Punchaw killa
raymi-field-start = Qallariy
raymi-field-end = Tukuy
raymi-field-location = Suytu
raymi-ph-location = Suytu (mana wakichisqachu)
raymi-field-description = Willakuy
raymi-ph-description = Qillqakuna (mana wakichisqachu)
raymi-field-attendees = Waqyasqakuna
raymi-ph-invitee = Suti <correo> · Enter
raymi-field-repeat = Kutichiy
raymi-field-every = Sapa
raymi-field-days = Punchaykuna
raymi-field-ends = Tukun
raymi-field-name = Suti
raymi-field-emails = Correos
raymi-field-phones = Qayakuna
raymi-field-org = Llankakuna
raymi-field-note = Qillqa
raymi-ph-full-name = Suti tukuynin
raymi-ph-emails = correo@dominio, sapa@…
raymi-ph-phones = +51 9…, …
raymi-ph-org = Llanka (mana wakichisqachu)
raymi-ph-note = Qillqa (mana wakichisqachu)
raymi-scope-series = Tukuy kutichiy
raymi-scope-this-only = Kay kausay sapan
raymi-scope-this-and-future = Kay kay qhipakuna
raymi-repeat-none = Mana kutichisqachu
raymi-repeat-daily = Sapa punchaw
raymi-repeat-weekly = Sapa saqra
raymi-repeat-monthly = Sapa killa
raymi-repeat-yearly = Sapa wata
raymi-unit-days = punchaw(kuna)
raymi-unit-weeks = saqra(kuna)
raymi-unit-months = killa(kuna)
raymi-unit-years = wata(kuna)
raymi-end-never = Mana tukukuqchu
raymi-end-count = N kutimanta qhipa
raymi-end-until = Punchaw kama
raymi-status-no-calendars = mana killay qillqachu kanchu
raymi-status-no-books = mana qillqa watachu kanchu
raymi-status-invalid-datetime = punchaw utaq hora mana allinchu
raymi-status-contact-needs-name = rimasqaqa sutita munan
# === shuma-shell-llimphi ===
shuma-shell-clear-input = Yaykuna pakay
shuma-shell-clear-screen = Rikuykuna pakay
shuma-shell-cancel-cmd = Kamachiy saqiy
shuma-shell-about = Shuma haqhay
# === supay-app-llimphi ===
supay-hud-health = KAWSAY
supay-hud-ammo = MAQANAKUY
supay-hud-target = QAWAY
supay-action-fire = Aysay
supay-action-reset = Qallarimuy
supay-menu-play = Pukllay
supay-status-game-over = pukllaypi tukuy
supay-status-victory = atiy
supay-status-dead = WAÑUSQA
supay-hint-space-restart = SPACE ninchu qallarimuy
# === wawa-panel-llimphi ===
wawa-panel-status-config-updated = ↻ churasqa huk llaqtamanta
wawa-panel-ctx-refresh-monitor = Mosqoychiy monitor
wawa-panel-autosave-ok = ↻ churasqa
+333
View File
@@ -0,0 +1,333 @@
//! `rimay-localize` — i18n del escritorio gioser sobre Fluent.
//!
//! Disciplina (PLAN.md §6.3):
//!
//! - Los `*-core` agnósticos **no contienen strings de UI**. Emiten
//! identificadores (`MsgId`) que las apps Llimphi resuelven a texto
//! localizado aquí, al renderizar.
//! - Un único catálogo `.ftl` por idioma vive en `locales/{lang}.ftl`,
//! embebido en el binario vía [`include_str!`].
//! - El locale activo es un singleton de proceso. Cambiarlo recarga el
//! bundle pero **no** retransla automáticamente la vista — las apps
//! Llimphi vuelven a llamar a [`t`] en el próximo `view()`.
//!
//! ## API mínima
//!
//! ```no_run
//! use rimay_localize as l10n;
//!
//! // Una vez al inicio de la app: detecta `LANG`/sistema, fallback es-PE.
//! l10n::init();
//!
//! // En cualquier `view()`:
//! let label = l10n::t("save");
//!
//! // Con argumentos posicionales tipo Fluent `{ $name }`:
//! let greet = l10n::t_args("welcome-user", &[("name", "Sergio".into())]);
//! ```
//!
//! ## Por qué Fluent (y no gettext / embeddings)
//!
//! - **Fluent** trae plurales/género/contextos declarativos en el `.ftl`,
//! sin macros de código. Esencial para idiomas aglutinantes (quechua)
//! donde la pluralización no es la binaria one/other del inglés.
//! - **Embeddings** son la herramienta correcta para *búsqueda semántica*
//! (command palette, intent → acción) — ver [`rimay-verbo`]. **No** para
//! strings deterministas de UI.
//!
//! ## Alcance fuera de wawa
//!
//! Este crate requiere `std` y `alloc` (Fluent tira de ambos). El kernel
//! `wawa` es `no_std` y no se localiza: emite **códigos** de error que
//! las apps WASM por encima traducen consultando este catálogo.
#![forbid(unsafe_code)]
use std::borrow::Cow;
use std::collections::HashMap;
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use thiserror::Error;
use tracing::warn;
use unic_langid::LanguageIdentifier;
// =====================================================================
// Catálogos embebidos
// =====================================================================
/// Lista de catálogos compilada al binario. Para añadir un idioma:
/// 1. Crear `locales/{lang}.ftl`.
/// 2. Añadir la tupla `("{lang}", include_str!(...))` aquí.
///
/// **Orden = prioridad declarada del proyecto**: español primero (es el
/// fallback y la lengua de trabajo), quechua segundo (lengua de la
/// arquitectura del monorepo), inglés tercero (uso técnico). Cambios
/// futuros conservan este orden por convención.
const CATALOGS: &[(&str, &str)] = &[
("es-PE", include_str!("../locales/es.ftl")),
("qu-PE", include_str!("../locales/qu.ftl")),
("en-US", include_str!("../locales/en.ftl")),
];
/// Locale por defecto cuando la detección del sistema falla o pide algo
/// que no tenemos catalogado.
pub const FALLBACK_LOCALE: &str = "es-PE";
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum LocalizeError {
#[error("locale '{0}' no está catalogado")]
UnknownLocale(String),
#[error("parseando catálogo de '{0}': {1}")]
CatalogParse(String, String),
#[error("identificador de locale inválido '{0}': {1}")]
InvalidLangId(String, String),
}
// =====================================================================
// Estado global
// =====================================================================
struct State {
/// Locale activo (clave de [`CATALOGS`]).
active: String,
/// Un bundle por catálogo embebido. Se construyen perezosamente la
/// primera vez que un locale entra en uso y se cachean.
bundles: HashMap<String, FluentBundle<FluentResource>>,
}
impl State {
fn new() -> Self {
Self {
active: FALLBACK_LOCALE.to_string(),
bundles: HashMap::new(),
}
}
fn ensure_bundle(&mut self, locale: &str) -> Result<(), LocalizeError> {
if self.bundles.contains_key(locale) {
return Ok(());
}
let src = CATALOGS
.iter()
.find(|(l, _)| *l == locale)
.map(|(_, s)| *s)
.ok_or_else(|| LocalizeError::UnknownLocale(locale.to_string()))?;
let langid: LanguageIdentifier = locale
.parse()
.map_err(|e: unic_langid::LanguageIdentifierError| {
LocalizeError::InvalidLangId(locale.to_string(), e.to_string())
})?;
let res = FluentResource::try_new(src.to_string()).map_err(|(_, errs)| {
LocalizeError::CatalogParse(
locale.to_string(),
errs.into_iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; "),
)
})?;
let mut bundle = FluentBundle::new_concurrent(vec![langid]);
// Fluent inserta caracteres bidi U+2068/U+2069 alrededor de los
// placeables. En una UI de escritorio que no soporta BIDI complejo
// (Llimphi no lo hace todavía) se ven como ◌. Los desactivamos:
// los catálogos no mezclan RTL/LTR por ahora.
bundle.set_use_isolating(false);
if let Err(errs) = bundle.add_resource(res) {
warn!(target: "rimay-localize", ?errs, "errores al añadir recurso a bundle '{locale}'");
}
self.bundles.insert(locale.to_string(), bundle);
Ok(())
}
}
static STATE: Lazy<RwLock<State>> = Lazy::new(|| RwLock::new(State::new()));
// =====================================================================
// API pública
// =====================================================================
/// Inicializa el localizador detectando el locale del sistema (env
/// `LANG`/`LC_ALL` vía [`sys_locale`]) y eligiendo el más cercano de los
/// catálogos disponibles. Si no hay match, cae en [`FALLBACK_LOCALE`].
///
/// Idempotente — invocaciones sucesivas resetean el locale activo según
/// el sistema actual. Si la app quiere fijar el locale a mano, usar
/// [`set_locale`] después.
pub fn init() {
let detected = sys_locale::get_locale().unwrap_or_else(|| FALLBACK_LOCALE.to_string());
let chosen = best_match(&detected).unwrap_or_else(|| FALLBACK_LOCALE.to_string());
let _ = set_locale(&chosen);
}
/// Cambia el locale activo. Compila el catálogo correspondiente si aún
/// no estaba cargado.
pub fn set_locale(locale: &str) -> Result<(), LocalizeError> {
let mut state = STATE.write();
state.ensure_bundle(locale)?;
state.active = locale.to_string();
Ok(())
}
/// Devuelve el locale activo (clave de [`CATALOGS`]).
pub fn current_locale() -> String {
STATE.read().active.clone()
}
/// Lista de locales disponibles (claves de [`CATALOGS`]).
pub fn available_locales() -> Vec<&'static str> {
CATALOGS.iter().map(|(l, _)| *l).collect()
}
/// Resuelve un mensaje sin argumentos. Si el ID no existe en el catálogo
/// activo, devuelve el propio ID — facilita ver qué falta traducir sin
/// crashear la UI.
pub fn t(id: &str) -> String {
resolve(id, None)
}
/// Resuelve un mensaje con argumentos posicionales tipo Fluent
/// (`{ $name }`). Los valores se convierten a [`FluentValue`] vía la
/// impl `From<Cow<str>>` — números pásalos pre-formateados como string
/// si querés controlar la presentación.
pub fn t_args(id: &str, args: &[(&str, Cow<'_, str>)]) -> String {
let mut fa = FluentArgs::new();
for (k, v) in args {
fa.set(*k, FluentValue::from(v.clone().into_owned()));
}
resolve(id, Some(&fa))
}
// =====================================================================
// Internos
// =====================================================================
fn resolve(id: &str, args: Option<&FluentArgs>) -> String {
// Auto-init lazy: si `t()` se llama sin `init()` previo (típico desde
// librerías como `cosmos-modules` que no ven el `main`), cargamos el
// fallback en ese momento. Es un costo amortizado de una sola vez.
if STATE.read().bundles.is_empty() {
let mut w = STATE.write();
if w.bundles.is_empty() {
let _ = w.ensure_bundle(FALLBACK_LOCALE);
w.active = FALLBACK_LOCALE.to_string();
}
}
let state = STATE.read();
let bundle = match state.bundles.get(&state.active) {
Some(b) => b,
None => return id.to_string(),
};
let Some(msg) = bundle.get_message(id) else {
return id.to_string();
};
let Some(pattern) = msg.value() else {
return id.to_string();
};
let mut errors = vec![];
let s = bundle.format_pattern(pattern, args, &mut errors);
if !errors.is_empty() {
warn!(target: "rimay-localize", ?errors, "errores formateando '{id}' en locale '{}'", state.active);
}
s.into_owned()
}
/// Mejor match entre un locale solicitado y los catalogados.
///
/// 1. Match exacto (`qu-PE` → `qu-PE`).
/// 2. Match por lengua base ignorando región (`es-AR` → `es-PE`,
/// `qu-BO` → `qu-PE`).
/// 3. Sin match → `None`.
fn best_match(requested: &str) -> Option<String> {
if CATALOGS.iter().any(|(l, _)| *l == requested) {
return Some(requested.to_string());
}
let base = requested.split(['-', '_']).next()?;
CATALOGS
.iter()
.find(|(l, _)| l.split('-').next() == Some(base))
.map(|(l, _)| l.to_string())
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Los tests comparten estado global → serialización manual.
static SERIAL: Mutex<()> = Mutex::new(());
#[test]
fn fallback_locale_resolves() {
let _g = SERIAL.lock().unwrap();
set_locale("es-PE").unwrap();
let s = t("save");
assert_eq!(s, "Guardar");
}
#[test]
fn switches_to_english() {
let _g = SERIAL.lock().unwrap();
set_locale("en-US").unwrap();
assert_eq!(t("save"), "Save");
assert_eq!(t("cancel"), "Cancel");
}
#[test]
fn quechua_loads() {
let _g = SERIAL.lock().unwrap();
set_locale("qu-PE").unwrap();
// Solo verificamos que devuelva *algo distinto* del id, no la
// traducción literal — para no acoplar el test al fraseo exacto
// del catálogo (que el revisor de quechua va a ajustar).
let s = t("save");
assert_ne!(s, "save", "qu-PE no resolvió 'save'");
}
#[test]
fn unknown_id_returns_id_as_degradation() {
let _g = SERIAL.lock().unwrap();
set_locale("es-PE").unwrap();
assert_eq!(t("__id_que_no_existe__"), "__id_que_no_existe__");
}
#[test]
fn args_interpolate() {
let _g = SERIAL.lock().unwrap();
set_locale("es-PE").unwrap();
let s = t_args("welcome-user", &[("name", "Sergio".into())]);
assert!(s.contains("Sergio"), "no interpoló name: {s}");
}
#[test]
fn best_match_region_fallback() {
assert_eq!(best_match("es-AR"), Some("es-PE".to_string()));
assert_eq!(best_match("en-GB"), Some("en-US".to_string()));
assert_eq!(best_match("qu-BO"), Some("qu-PE".to_string()));
assert_eq!(best_match("ja-JP"), None);
}
#[test]
fn unknown_locale_errors() {
let err = set_locale("xx-YY").unwrap_err();
assert!(matches!(err, LocalizeError::UnknownLocale(_)));
}
#[test]
fn available_locales_lists_all() {
let v = available_locales();
assert!(v.contains(&"es-PE"));
assert!(v.contains(&"en-US"));
assert!(v.contains(&"qu-PE"));
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "wawa-config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
directories = { workspace = true }
notify = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
+35
View File
@@ -0,0 +1,35 @@
# wawa-config — bus de configuración del SO
El **bus de configuración** del escritorio/SO: un archivo TOML canónico
(`~/.config/wawa/config.toml`) + un watcher (`notify`) que reemite cambios en
vivo, sobre una capa de sistema (`/etc/wawa/config.toml`) que el usuario puede
override-ar. Los consumidores (apps Llimphi del escritorio) se suscriben y
reaccionan al vuelo: cambiar theme/acento se propaga **sin reiniciar**.
UI-agnóstico: **no depende de `llimphi`**. El adaptador que ensambla un `Theme`
efectivo a partir del `WawaConfig` vive en `wawa-config-llimphi`.
## Qué expone
- `WawaConfig` — la configuración (variant de theme, accent override, …).
- Carga con merge `/etc/wawa` (sistema) bajo override de usuario.
- Watcher (`notify`) que reemite el config al cambiar el archivo.
## Estado (2026-05-31)
### Hecho
- Archivo canónico TOML + watcher `notify` (live reload).
- Capa de sistema `/etc/wawa/config.toml` mergeada bajo el override de usuario.
- Auto-apply del acento al theme global; ≈10 tests.
- Consumido por nada, cosmos, nakui, dominium, shuma, nahual, minga, arje,
wawa-panel y `wawactl` (CLI).
### Pendiente
- Esquema de config más amplio (más que theme/acento).
- Validación/migración de versiones del TOML.
- Consumo desde el SO wawa bare-metal (hoy es el escritorio host).
## Lugar en el repo
`shared/wawa-config` — fuente de verdad UI-agnóstica. Adaptador de theme:
`wawa-config-llimphi`. CLI: `wawactl`.
+762
View File
@@ -0,0 +1,762 @@
//! `wawa-config` — bus de configuración del SO wawa.
//!
//! Dos capas de archivos JSON canónicos actúan como medio:
//!
//! 1. **Sistema** — `/etc/wawa/config.json` (Linux). Defaults
//! machine-wide; lo escribe el admin con `wawactl --system set …`
//! (requiere root) o un instalador.
//! 2. **Usuario** — `$XDG_CONFIG_HOME/wawa/config.json` (Linux:
//! `~/.config/wawa/config.json`). Lo que escribe el panel y las
//! apps; **sobreescribe** campo por campo a la capa de sistema.
//!
//! El panel de control y los daemons escriben; las apps Llimphi leen y
//! se suscriben a cambios vía [`notify::RecommendedWatcher`] sobre
//! **ambos** paths.
//!
//! ## Nota sobre `/etc/wawa`
//!
//! `/etc/` es una convención de Unix/Linux. Cuando wawa sea su propio
//! SO (no un userland sobre Linux), esta capa se reemplazará por el
//! mecanismo nativo de "config de sistema" que defina arje — la API
//! pública (`load`, `system_path`, `user_path`) se mantiene; sólo
//! cambia lo que devuelve `system_path()` adentro.
//!
//! Por qué archivo + `notify` y no un daemon pub-sub:
//!
//! * **Cero dependencias en runtime**: ninguna app necesita que un
//! daemon esté arrancado para leer la config; basta con que el
//! archivo exista (y si no existe, devuelve defaults).
//! * **Auditable y editable a mano**: el archivo es JSON con `pretty`,
//! el admin lo abre con cualquier editor o lo edita por sed/jq.
//! * **Atomicidad simple**: `save()` escribe a `config.json.tmp` y
//! `rename()` — los watchers ven un único evento de creación que
//! contiene la versión completa.
//! * **Compatible con apps existentes**: el modelo de Llimphi ya
//! reentra al `update` vía `Handle::dispatch`; el watcher dispara
//! un Msg del consumidor cuando llega el evento.
//!
//! ## Forma del archivo
//!
//! ```json
//! {
//! "theme_variant": "dark",
//! "accent": "default",
//! "lang": "es-PE",
//! "timefmt_24h": true,
//! "modules": {
//! "mirada": true,
//! "shuma": true,
//! "chasqui": true,
//! "akasha": true,
//! "minga": true,
//! "agora": true
//! }
//! }
//! ```
//!
//! Campos desconocidos se ignoran al deserializar; campos ausentes
//! caen al default. Esto permite agregar nuevas keys sin romper
//! consumidores antiguos.
//!
//! ## Productor
//!
//! ```ignore
//! use wawa_config::WawaConfig;
//!
//! let mut cfg = WawaConfig::load();
//! cfg.theme_variant = "aurora".into();
//! cfg.save()?;
//! ```
//!
//! ## Consumidor (app Llimphi)
//!
//! ```ignore
//! use wawa_config::{WawaConfig, ConfigWatcher};
//!
//! // En `App::init`:
//! let handle = handle.clone();
//! let watcher = ConfigWatcher::spawn(move |cfg| {
//! handle.dispatch(Msg::ConfigChanged(cfg));
//! })?;
//! // Guardar `watcher` en el Model para que viva todo lo que vive la app.
//! ```
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
/// Nombre del subdirectorio dentro de XDG_CONFIG_HOME donde vive el
/// archivo. Exporto la constante para que tests y herramientas
/// externas lo puedan inspeccionar.
pub const CONFIG_DIR: &str = "wawa";
/// Nombre del archivo canónico.
pub const CONFIG_FILE: &str = "config.json";
/// Directorio de la capa de sistema en Linux. Cuando wawa sea su
/// propio SO esta ruta se reemplaza por lo que defina arje; la API
/// pública (`system_config_path`) se mantiene.
pub const SYSTEM_CONFIG_DIR_LINUX: &str = "/etc/wawa";
/// Capa de la cual se cargó/escribió una config. Útil para herramientas
/// que necesiten distinguir explícitamente entre sistema y usuario.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Layer {
/// `/etc/wawa/config.json` — defaults machine-wide, requiere root.
System,
/// `$XDG_CONFIG_HOME/wawa/config.json` — override por usuario.
User,
}
/// Mapea el `theme_variant` de la config (lowercase, libre) al nombre
/// canónico que reconoce `llimphi_theme::Theme::by_name` (capitalizado).
/// Devuelve `None` si el variant no es uno de los presets conocidos —
/// el consumidor decide qué hacer (fallback a dark, error, etc.).
///
/// Los presets de Llimphi tienen `name: &'static str` capitalizado;
/// los users del CLI y el panel escriben en lowercase. Este shim
/// mantiene a `wawa-config` UI-agnóstico (no depende de
/// `llimphi-theme`) y a la vez evita que cada consumidor reimplemente
/// el casing.
pub fn canonical_theme_name(variant: &str) -> Option<&'static str> {
match variant.to_ascii_lowercase().as_str() {
"dark" => Some("Dark"),
"light" => Some("Light"),
"aurora" => Some("Aurora"),
"sunset" => Some("Sunset"),
_ => None,
}
}
/// Devuelve el color RGB de un acento por id. `default` retorna `None`
/// para que el consumidor no toque el accent del theme base. La paleta
/// es la misma del web (`gioser-web/styles.css`): tinte por cuadrante
/// + accent gioser por default.
///
/// Es un trio RGB (no un tipo de `peniko`) para no obligar a depender
/// de `llimphi-raster` desde acá. Los consumidores Llimphi hacen:
///
/// ```ignore
/// if let Some([r,g,b]) = wawa_config::accent_rgb(&cfg.accent) {
/// let c = llimphi_theme::Color::from_rgba8(r, g, b, 255);
/// theme.accent = c;
/// theme.border_focus = c;
/// }
/// ```
pub fn accent_rgb(accent: &str) -> Option<[u8; 3]> {
match accent {
"default" => None,
"gioser" => Some([0x6E, 0x8C, 0xDC]),
"unanchay" => Some([0xB9, 0xC9, 0xE8]),
"yachay" => Some([0xE8, 0xC9, 0x7A]),
"ruway" => Some([0xE8, 0x9B, 0x6E]),
"ukupacha" => Some([0x8F, 0xB5, 0x8C]),
_ => None,
}
}
/// Lista de variants de theme reconocidas — útil para validadores y
/// generadores de docs/UI. Orden estable.
pub const THEME_VARIANTS: &[&str] = &["dark", "light", "aurora", "sunset"];
/// Lista de acentos reconocidos. `"default"` significa "no override".
pub const ACCENTS: &[&str] = &["default", "gioser", "unanchay", "yachay", "ruway", "ukupacha"];
/// Identificadores estables de los módulos del SO conocidos. Las apps
/// son libres de leer/escribir otros, pero estos son los que el panel
/// expone por default — mantenerlos como `const` ayuda a no escribir
/// el string mal en sitios distintos.
pub mod modules {
pub const MIRADA: &str = "mirada";
pub const SHUMA: &str = "shuma";
pub const CHASQUI: &str = "chasqui";
pub const AKASHA: &str = "akasha";
pub const MINGA: &str = "minga";
pub const AGORA: &str = "agora";
pub fn defaults() -> [(&'static str, bool); 6] {
[
(MIRADA, true),
(SHUMA, true),
(CHASQUI, true),
(AKASHA, true),
(MINGA, true),
(AGORA, true),
]
}
}
/// Configuración del sistema operativo wawa. Serializada como el JSON
/// del módulo. Campos nuevos se agregan con `#[serde(default = "…")]`
/// para preservar compatibilidad hacia atrás.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WawaConfig {
/// Variante del theme global. Coincide con
/// `llimphi_theme::Theme::name`: `"dark"`, `"light"`, `"aurora"`,
/// `"sunset"`.
#[serde(default = "default_theme_variant")]
pub theme_variant: String,
/// Acento. `"default"` deja el accent del theme; cualquier otro
/// id (gioser/unanchay/yachay/ruway/ukupacha) lo sobreescribe.
#[serde(default = "default_accent")]
pub accent: String,
/// Locale activo. Acepta lo mismo que `rimay_localize::set_locale`:
/// `"es-PE"`, `"en-US"`, `"qu-PE"`.
#[serde(default = "default_lang")]
pub lang: String,
/// Formato del reloj (true = 24h, false = 12h con am/pm).
#[serde(default = "default_timefmt")]
pub timefmt_24h: bool,
/// Estado on/off de los módulos del SO. Usa los ids de
/// [`modules`]. BTreeMap → serializa con orden estable y diffs
/// limpios en git.
#[serde(default = "default_modules")]
pub modules: BTreeMap<String, bool>,
}
fn default_theme_variant() -> String {
"dark".into()
}
fn default_accent() -> String {
"default".into()
}
fn default_lang() -> String {
"es-PE".into()
}
fn default_timefmt() -> bool {
true
}
fn default_modules() -> BTreeMap<String, bool> {
modules::defaults()
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
impl Default for WawaConfig {
fn default() -> Self {
Self {
theme_variant: default_theme_variant(),
accent: default_accent(),
lang: default_lang(),
timefmt_24h: default_timefmt(),
modules: default_modules(),
}
}
}
impl WawaConfig {
/// `true` si el módulo `id` está activo (default: activo si no se
/// conoce — convención conservadora).
pub fn module_enabled(&self, id: &str) -> bool {
self.modules.get(id).copied().unwrap_or(true)
}
/// Conmuta el módulo `id`. Si no existía, lo agrega con `false`.
pub fn toggle_module(&mut self, id: &str) {
let v = self.modules.entry(id.to_string()).or_insert(true);
*v = !*v;
}
/// Path canónico del archivo de usuario (alias de [`user_config_path`]).
/// `None` si la plataforma no expone un config dir (extremadamente
/// raro fuera de embebidos).
pub fn path() -> Option<PathBuf> {
user_config_path()
}
/// Path canónico del archivo de la capa indicada. `None` si la
/// capa no aplica en esta plataforma (p. ej. `Layer::System` fuera
/// de Linux).
pub fn path_for(layer: Layer) -> Option<PathBuf> {
match layer {
Layer::System => system_config_path(),
Layer::User => user_config_path(),
}
}
/// Carga la config efectiva: defaults → capa de sistema → capa de
/// usuario. Cada capa **sobreescribe campo por campo** lo que
/// definió la anterior; campos ausentes preservan el valor
/// previo. Para `modules`, el merge es key-by-key (no reemplazo
/// total del mapa).
///
/// Si ningún archivo existe, o están corruptos, devuelve defaults
/// — nunca falla. Los errores se loggean a `tracing::warn`.
pub fn load() -> Self {
let mut acc = serde_json::to_value(Self::default())
.expect("WawaConfig::default siempre serializa");
for layer in [Layer::System, Layer::User] {
if let Some(v) = load_layer_value(layer) {
merge_json(&mut acc, v);
}
}
serde_json::from_value(acc).unwrap_or_default()
}
/// Carga **sólo** la capa indicada, sin mergear con la otra. Útil
/// para herramientas como `wawactl --system show` que necesitan
/// inspeccionar una capa concreta. Si el archivo no existe,
/// devuelve `None` (no defaults — distingue "ausente" de
/// "presente con defaults"). Errores de parseo loggean warn y
/// también devuelven `None`.
pub fn load_layer(layer: Layer) -> Option<Self> {
let path = Self::path_for(layer)?;
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(?path, error = %e, "wawa-config: read failed");
return None;
}
};
match serde_json::from_slice::<WawaConfig>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
warn!(?path, error = %e, "wawa-config: parse failed");
None
}
}
}
/// Persiste atómicamente en la capa de **usuario** (compat con
/// callers existentes): serializa a `config.json.tmp` y renombra
/// sobre `config.json`. Crea el directorio padre si no existe.
pub fn save(&self) -> Result<PathBuf, ConfigError> {
self.save_to(Layer::User)
}
/// Persiste en la capa indicada. `Layer::System` apunta a
/// `/etc/wawa/config.json` y típicamente requiere root — devuelve
/// `ConfigError::Io` con `PermissionDenied` si no.
pub fn save_to(&self, layer: Layer) -> Result<PathBuf, ConfigError> {
let path = Self::path_for(layer).ok_or(ConfigError::NoProjectDirs)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &path)?;
Ok(path)
}
}
/// Lee y parsea una capa como `serde_json::Value`. Devuelve `None`
/// (no defaults) si el archivo no existe o falla el parse — esto es
/// distinto del `WawaConfig::load_layer` que también devuelve Option,
/// pero acá trabajamos con Value para mergear sin perder "campo
/// ausente vs explícito".
fn load_layer_value(layer: Layer) -> Option<serde_json::Value> {
let path = WawaConfig::path_for(layer)?;
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(?path, error = %e, "wawa-config: read failed");
return None;
}
};
match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(v) => Some(v),
Err(e) => {
warn!(?path, error = %e, "wawa-config: parse failed");
None
}
}
}
/// Merge profundo: `over` sobreescribe `base` hoja por hoja, recursivo
/// sobre objetos JSON. Para arrays y escalares, `over` reemplaza
/// completamente. Esto preserva la semántica "campo ausente → no
/// modifica la capa inferior" y permite que un user override sólo
/// algunas keys de `modules`.
fn merge_json(base: &mut serde_json::Value, over: serde_json::Value) {
use serde_json::Value;
match (base, over) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(&k) {
Some(existing) => merge_json(existing, v),
None => {
b.insert(k, v);
}
}
}
}
(slot, v) => *slot = v,
}
}
/// Errores de IO o serialización al persistir la config. La carga
/// nunca falla — devuelve defaults en su lugar.
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("no hay ProjectDirs en esta plataforma")]
NoProjectDirs,
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
#[error("notify: {0}")]
Notify(#[from] notify::Error),
}
/// Path del archivo de **usuario**. El qualifier "" + organization ""
/// se mapea a `$XDG_CONFIG_HOME/wawa/` en Linux (típicamente
/// `~/.config/wawa/`), `~/Library/Application Support/wawa/` en
/// macOS, `%APPDATA%/wawa/` en Windows. `None` si la plataforma no
/// expone un config dir.
pub fn user_config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", CONFIG_DIR)
.map(|d| d.config_dir().join(CONFIG_FILE))
}
/// Path del archivo de **sistema**. `Some("/etc/wawa/config.json")`
/// en Linux; `None` en otras plataformas (no hay convención
/// equivalente y no vale la pena inventarla). Cuando wawa sea su
/// propio SO, esta función devolverá el equivalente nativo y la API
/// pública no cambia.
pub fn system_config_path() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX).join(CONFIG_FILE))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
// =====================================================================
// Watcher
// =====================================================================
/// Suscripción al bus. Mantenelo vivo (guardalo en el Model de tu app)
/// para seguir recibiendo notificaciones; al dropearlo, los callbacks
/// dejan de dispararse.
///
/// Observa **ambas capas** (sistema y usuario): un cambio en
/// cualquiera dispara `on_change` con la config efectiva ya mergeada.
/// Cada capa escucha el directorio padre con
/// `RecursiveMode::NonRecursive` y filtra por `config.json` — así
/// detecta tanto modificaciones in-place como reemplazos atómicos por
/// `rename`. Si la capa de sistema no aplica en la plataforma (no
/// Linux), o no se puede crear/observar (p. ej. `/etc/wawa` sin
/// permisos de lectura — improbable porque `/etc/` es world-readable
/// por convención), se ignora con un warn y el watcher sigue activo
/// sólo sobre la capa de usuario.
///
/// Para evitar disparar dos veces seguidas cuando un editor escribe
/// con la secuencia `truncate → write → close`, el watcher debouncea
/// internamente con un timeout de ~200 ms: agrupa eventos consecutivos
/// y emite un único callback con la última versión leída.
pub struct ConfigWatcher {
_watchers: Vec<RecommendedWatcher>,
_debounce_thread: Option<thread::JoinHandle<()>>,
}
impl ConfigWatcher {
/// Arranca el watcher. `on_change` se llama cada vez que **alguna**
/// de las capas cambia, ya con la nueva config efectiva mergeada
/// (sistema ← usuario). Si el parseo falla, no se invoca (se
/// loggea como warn y se ignora hasta el próximo cambio).
///
/// `on_change` corre en un thread propio del watcher — para
/// reentrar al loop de Llimphi, capturá un `Handle<Msg>` clonado
/// y llamá `handle.dispatch(...)` dentro de la closure.
pub fn spawn<F>(on_change: F) -> Result<Self, ConfigError>
where
F: FnMut(WawaConfig) + Send + 'static,
{
let (tx, rx) = mpsc::channel::<()>();
let mut watchers = Vec::with_capacity(2);
// Capa de usuario es obligatoria; si no hay ProjectDirs es un
// entorno raro y devolvemos error como antes.
let user_path = user_config_path().ok_or(ConfigError::NoProjectDirs)?;
watchers.push(spawn_layer_watcher(&user_path, tx.clone(), /*must_exist=*/ true)?);
// Capa de sistema es best-effort: si no aplica (no Linux), o
// no se puede observar (sin permiso de lectura de `/etc/wawa`,
// que es muy raro pero posible si el admin la chmodea), no
// rompe — sólo no se entera de cambios de sistema.
if let Some(sys_path) = system_config_path() {
match spawn_layer_watcher(&sys_path, tx.clone(), /*must_exist=*/ false) {
Ok(w) => watchers.push(w),
Err(e) => warn!(?sys_path, error = %e, "wawa-config: system layer watch skipped"),
}
}
// Debounce: junta señales durante ~200 ms y al cierre llama
// `on_change` con la lectura más reciente (mergeada). Acepta
// que perdamos ráfagas intermedias — solo importa el estado
// final.
let debounce = thread::Builder::new()
.name("wawa-config-debounce".into())
.spawn(move || debounce_loop(rx, Box::new(on_change)))
.ok();
Ok(Self {
_watchers: watchers,
_debounce_thread: debounce,
})
}
}
fn spawn_layer_watcher(
path: &Path,
tx: mpsc::Sender<()>,
must_exist: bool,
) -> Result<RecommendedWatcher, ConfigError> {
let parent = path
.parent()
.ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"config path sin parent",
))
})?
.to_path_buf();
// Para la capa de usuario creamos el dir si falta — notify puede
// watchear un dir vacío. Para la de sistema no lo creamos: si
// `/etc/wawa` no existe, probablemente esta máquina no usa la capa
// de sistema y mejor no requerir permisos de root para correr una
// app de usuario.
if must_exist {
std::fs::create_dir_all(&parent)?;
} else if !parent.exists() {
return Err(ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"directorio de capa de sistema ausente",
)));
}
let target_name = path.file_name().map(|n| n.to_owned());
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let event = match res {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "wawa-config: watcher error");
return;
}
};
let is_target = match &target_name {
Some(name) => event
.paths
.iter()
.any(|p| p.file_name().map(|f| f == name.as_os_str()).unwrap_or(false)),
None => true,
};
if !is_target {
return;
}
if !matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return;
}
let _ = tx.send(());
})?;
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
Ok(watcher)
}
fn debounce_loop(rx: mpsc::Receiver<()>, mut on_change: Box<dyn FnMut(WawaConfig) + Send>) {
const QUIET: Duration = Duration::from_millis(200);
loop {
// Esperar al primer evento sin timeout.
if rx.recv().is_err() {
return;
}
// Drenar lo que se acumule en la ventana de quiet.
loop {
match rx.recv_timeout(QUIET) {
Ok(()) => continue,
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
let cfg = WawaConfig::load();
on_change(cfg);
}
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_round_trip() {
let c = WawaConfig::default();
let s = serde_json::to_string(&c).unwrap();
let back: WawaConfig = serde_json::from_str(&s).unwrap();
assert_eq!(c, back);
}
#[test]
fn partial_json_uses_defaults() {
// Sólo se aporta theme; el resto debe caer al default.
let s = r#"{"theme_variant":"aurora"}"#;
let c: WawaConfig = serde_json::from_str(s).unwrap();
assert_eq!(c.theme_variant, "aurora");
assert_eq!(c.accent, "default");
assert_eq!(c.lang, "es-PE");
assert!(c.timefmt_24h);
assert!(c.module_enabled(modules::MIRADA));
}
#[test]
fn unknown_fields_ignored() {
// Un campo extra no rompe la deserialización.
let s = r#"{"theme_variant":"dark","unknown":42}"#;
let _c: WawaConfig = serde_json::from_str(s).unwrap();
}
#[test]
fn toggle_module_persists_value() {
let mut c = WawaConfig::default();
assert!(c.module_enabled(modules::MIRADA));
c.toggle_module(modules::MIRADA);
assert!(!c.module_enabled(modules::MIRADA));
c.toggle_module("inexistente");
assert!(!c.module_enabled("inexistente"));
}
#[test]
fn canonical_theme_maps_variants() {
assert_eq!(canonical_theme_name("dark"), Some("Dark"));
assert_eq!(canonical_theme_name("LIGHT"), Some("Light"));
assert_eq!(canonical_theme_name("Aurora"), Some("Aurora"));
assert_eq!(canonical_theme_name("sunset"), Some("Sunset"));
assert_eq!(canonical_theme_name("hyperdark"), None);
}
#[test]
fn accent_rgb_default_is_none() {
assert_eq!(accent_rgb("default"), None);
assert_eq!(accent_rgb("gioser"), Some([0x6E, 0x8C, 0xDC]));
assert_eq!(accent_rgb("ukupacha"), Some([0x8F, 0xB5, 0x8C]));
assert_eq!(accent_rgb("desconocido"), None);
}
#[test]
fn merge_user_over_system_overrides_field_by_field() {
// Sistema define theme aurora y lang qu-PE; usuario sólo
// sobreescribe lang. Resultado: theme aurora (de sistema),
// lang en-US (de usuario).
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
let system: serde_json::Value =
serde_json::from_str(r#"{"theme_variant":"aurora","lang":"qu-PE"}"#).unwrap();
let user: serde_json::Value =
serde_json::from_str(r#"{"lang":"en-US"}"#).unwrap();
merge_json(&mut base, system);
merge_json(&mut base, user);
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
assert_eq!(final_cfg.theme_variant, "aurora");
assert_eq!(final_cfg.lang, "en-US");
// El resto cae al default.
assert_eq!(final_cfg.accent, "default");
}
#[test]
fn merge_modules_is_deep_per_key() {
// Sistema apaga mirada; usuario apaga shuma. Esperado: ambos
// off, el resto en su default true.
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
let system: serde_json::Value =
serde_json::from_str(r#"{"modules":{"mirada":false}}"#).unwrap();
let user: serde_json::Value =
serde_json::from_str(r#"{"modules":{"shuma":false}}"#).unwrap();
merge_json(&mut base, system);
merge_json(&mut base, user);
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
assert!(!final_cfg.module_enabled(modules::MIRADA));
assert!(!final_cfg.module_enabled(modules::SHUMA));
assert!(final_cfg.module_enabled(modules::CHASQUI));
}
#[test]
fn system_path_only_on_linux() {
let p = system_config_path();
if cfg!(target_os = "linux") {
assert_eq!(p, Some(PathBuf::from("/etc/wawa/config.json")));
} else {
assert!(p.is_none());
}
}
#[test]
fn constants_match_helpers() {
// THEME_VARIANTS y ACCENTS deben coincidir con lo que aceptan
// los helpers — guarda contra agregar uno y olvidar el otro.
for v in THEME_VARIANTS {
assert!(canonical_theme_name(v).is_some(), "variant {v} sin mapeo");
}
for a in ACCENTS {
// accent_rgb("default") es None por diseño; el resto debe
// tener color asignado.
if *a == "default" {
assert_eq!(accent_rgb(a), None);
} else {
assert!(accent_rgb(a).is_some(), "accent {a} sin color");
}
}
}
}
/// Path absoluto del config dir de **usuario** — alias por compat.
/// `None` si no hay ProjectDirs disponibles.
pub fn config_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", CONFIG_DIR).map(|d| d.config_dir().to_path_buf())
}
/// Path absoluto del config dir de **sistema** (`/etc/wawa` en Linux).
/// `None` en otras plataformas.
pub fn system_config_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// Helper opcional: agrega el `path` provisto a una lista de
/// watchers. No es parte del flujo normal — está expuesto para
/// herramientas que quieran observar un directorio externo (p. ej.
/// `/etc/wawa/` para configuración del sistema vs el del usuario).
/// El default (`spawn`) ya cubre el caso típico.
pub fn watch_path(
p: &Path,
on_event: impl FnMut(notify::Event) + Send + 'static,
) -> Result<RecommendedWatcher, ConfigError> {
let mut on_event = on_event;
let mut w =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
Ok(ev) => on_event(ev),
Err(e) => warn!(error = %e, "wawa-config: external watch error"),
})?;
w.watch(p, RecursiveMode::NonRecursive)?;
Ok(w)
}