From 80a264f02f7f1672501a7288c86f62b35dd93abc Mon Sep 17 00:00:00 2001 From: jose luis <2064537+sosan@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:37:55 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(secrets):=20smoke=20de=20migraci=C3=B3?= =?UTF-8?q?n=20legacy=20aislado=20+=20README=20(issue=20#106,=20AC2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ejercita migrate_legacy() en Secret Service real del CI (run 40651008234) según la revisión del owner (2026-10-28): - smoke #[ignore] linux_legacy_migrate_once_then_legacy_is_gone: puebla LEGACY_SERVICE (es.egdev.ai-usage) con una clave base (nan_session, sin @: las claves con @ no migran por diseño), get() la recupera por migración, queda reescrita en el destino actual (es.egdev.quorb) y el legado devuelve NoEntry. Sin imprimir valores. - AISLAMIENTO OBLIGATORIO: el smoke exige QUORB_SECRETS_SMOKE_ISOLATED=1 (definida solo dentro del dbus-run-session + gnome-keyring efímero del workflow) y falla antes de tocar nada sin ella — nunca un escritorio con llavero real. CleanupGuard creado antes de cualquier lectura y con distinción NoEntry vs error de lectura en la precondición. - linux-ci.yml: el paso "Secret Service smoke" exporta la var de aislamiento y corre ambos smokes. - README de evidencia: la migración pasa de pendiente a ejercitada, con el run enlazado y la ejecución segura documentada (solo vía CI aislado, no manual en escritorio). Co-Authored-By: Claude Code --- .github/workflows/linux-ci.yml | 25 +++++-- docs/linux-l1-evidence/README.md | 35 +++++++-- src-tauri/src/secrets.rs | 123 +++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 22 deletions(-) diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index c0b3c13..bb3b14a 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -107,13 +107,20 @@ jobs: run: cargo test --locked # ---- W3 #106: Secret Service smoke (Linux real) ---- - # El smoke `linux_secret_service_round_trip` (secrets.rs) está `#[ignore]` - # porque necesita un Secret Service de escritorio real (D-Bus + libsecret). - # Aquí se monta uno efímero: `dbus-run-session` crea un session bus aislado - # (y ya exporta DBUS_SESSION_BUS_ADDRESS) y `gnome-keyring-daemon --unlock` - # levanta el servicio con una contraseña de prueba propia del runner. - # El test guarda/lee/borra un valor de prueba y valida el backend - # `linux-native` del crate `keyring` — criterio 2 de la issue #106. + # Los smokes `linux_secret_service_round_trip` y + # `linux_legacy_migrate_once_then_legacy_is_gone` (secrets.rs) están + # `#[ignore]` porque necesitan un Secret Service real (D-Bus + + # libsecret). Aquí se monta uno efímero: `dbus-run-session` crea un + # session bus aislado (y ya exporta DBUS_SESSION_BUS_ADDRESS) y + # `gnome-keyring-daemon --unlock` levanta el servicio con una contraseña + # de prueba propia del runner. + # El primero guarda/lee/borra un valor de prueba (backend `linux-native` + # del crate `keyring`); el segundo puebla el servicio LEGADO + # (es.egdev.ai-usage) y verifica que get() migra al destino actual, + # que el valor se reescribe y que el legado desaparece (criterio AC2). + # QUORB_SECRETS_SMOKE_ISOLATED=1 se define SOLO dentro de esta sesión + # aislada: el smoke de migración falla sin ella, así nunca toca un + # llavero real de un escritorio. - name: Secret Service smoke (gnome-keyring, D-Bus session) run: | echo "► Arrancando Secret Service efímero (dbus-run-session + gnome-keyring)..." @@ -121,9 +128,13 @@ jobs: eval $(printf "%s" "quorb-ci-testpass" | gnome-keyring-daemon --unlock --components=secrets) echo " DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" echo " GNOME_KEYRING_CONTROL=${GNOME_KEYRING_CONTROL:-}" + export QUORB_SECRETS_SMOKE_ISOLATED=1 cd src-tauri echo "► Corriendo smoke linux_secret_service_round_trip (--ignored)..." cargo test --locked linux_secret_service_round_trip -- --ignored --nocapture + echo "► Corriendo smoke linux_legacy_migrate_once_then_legacy_is_gone (--ignored)..." + cargo test --locked linux_legacy_migrate_once_then_legacy_is_gone -- --ignored --nocapture + echo " QUORB_SECRETS_SMOKE_ISOLATED=$QUORB_SECRETS_SMOKE_ISOLATED (aislamiento verificado)" ' # ---- Solo si with_bundle == bundle ---- diff --git a/docs/linux-l1-evidence/README.md b/docs/linux-l1-evidence/README.md index 0515a4b..5dbba9e 100644 --- a/docs/linux-l1-evidence/README.md +++ b/docs/linux-l1-evidence/README.md @@ -98,14 +98,33 @@ Lo que demuestra (criterio 2 de #106): - Se **borra** (`set(&key, "")` → `get` devuelve `None`). - Usa la misma vía `entry()`/`scoped()` que la app, con una clave de la allowlist — no escribe en disco ni en texto plano. -**Pendiente del criterio 2 (revisión del owner, 2026-10-23)**: la **migración -del servicio legacy** (`migrate_legacy()` en `secrets.rs`: copia un secreto de -`LEGACY_SERVICE` al servicio actual y borra el antiguo) **no se ejercita en la -evidencia**. Requiere un Secret Service real con una clave legacy presente; los -tests existentes cubren el invariante «no leak / no degradar a texto plano», -pero no el flujo de migración en sí. Se registra como pendiente: validarlo en -el mismo step del smoke (escribir en `LEGACY_SERVICE`, llamar `get` con la -clave, comprobar que migró y que el legacy se borró) o en una máquina real. +**Migración del servicio legacy — ejercitada (criterio 2, revisión del owner 2026-10-28)**: +la migración (`migrate_legacy()` en `secrets.rs`: copia un secreto de +`LEGACY_SERVICE` al servicio actual y borra el antiguo) queda validada por el +smoke `linux_legacy_migrate_once_then_legacy_is_gone` en el run +[`40651008234`](https://github.com/sosan/quorb/actions/runs/40651008234) +(SHA `0c75fab`), log: `linux_legacy_migrate_once_then_legacy_is_gone ... ok`. +Qué verifica: +- Escribe **directamente** en `LEGACY_SERVICE` (`es.egdev.ai-usage`) una clave + base de la allowlist (`nan_session`, sin `@` — las claves con `@` no migran + por diseño de `get()`), sin pasar por `set`/`get`. +- `get()` con el destino actual vacío la recupera **por la vía de migración** + (se lee una vez). +- El valor queda reescrito en el **destino actual** (`es.egdev.quorb`), + comprobado leyendo el entry actual sin volver a migrar. +- El legado desaparece de lecturas directas posteriores (**`NoEntry`**). +- Sin imprimir valores; `CleanupGuard` limpia ambos servicios al terminar. + +**Ejecución segura (aislamiento obligatorio)**: el smoke escribe en la clave +real `nan_session` del llavero, así que **solo se ejecuta en el entorno +aislado del CI** — `dbus-run-session` + `gnome-keyring` efímero con +`QUORB_SECRETS_SMOKE_ISOLATED=1` (var definida únicamente dentro de esa sesión +en `linux-ci.yml`). Sin esa variable, el smoke **falla antes de tocar nada**: +no debe ejecutarse a mano en un escritorio con llavero real. El `CleanupGuard` +se crea antes de cualquier lectura y su limpieza solo corre en ese entorno +aislado, así que nunca borra datos reales. Los smokes de llavero (round trip y +migración) se ejecutan únicamente vía el paso "Secret Service smoke" de +`linux-ci.yml`, no como ejecución manual en escritorio. Dependencias añadidas al runner: `libsecret-1-dev`, `gnome-keyring`, `dbus`, `dbus-x11`, `xvfb`. diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index d30c0e4..e898630 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -261,15 +261,16 @@ mod tests { assert!(get("api_key_cualquiera@openrouter:a1b2c3").is_none()); } - /// Smoke manual de Secret Service real: solo corre en Linux con un - /// escritorio con D-Bus + llavero activo. Marcado `#[ignore]` para que - /// no forme parte del `cargo test` de CI. Ejecutar con - /// `cargo test -- --ignored --nocapture` en una sesión de escritorio. - /// Verifica que el backend `linux-native` del crate `keyring` persiste - /// y recupera el valor a través de Secret Service. + /// Smoke manual de Secret Service real: verifica que el backend + /// `linux-native` del crate `keyring` persiste y recupera el valor a + /// través de Secret Service. Marcado `#[ignore]` para que no forme parte + /// del `cargo test` de CI. NO se ejecuta a mano en un escritorio: corre + /// en el entorno AISLADO del CI (`dbus-run-session` + `gnome-keyring` + /// efímero, paso "Secret Service smoke" de linux-ci.yml), que es la única + /// vía soportada para los smokes de llavero. #[cfg(target_os = "linux")] #[test] - #[ignore = "requiere escritorio Linux con D-Bus + Secret Service"] + #[ignore = "requiere llavero aislado del CI"] fn linux_secret_service_round_trip() { // Clave única por corrida y propia de la lista cerrada: `entry` rechaza // cualquier clave ajena, así que el smoke usa la misma vía que la app. @@ -310,4 +311,112 @@ mod tests { set(&key, "").expect("borrado con set vacío"); assert!(get(&key).is_none(), "la clave debería quedar eliminada"); } + + /// Smoke manual de MIGRACIÓN del servicio legado, también `#[ignore]`: + /// pobla el espacio legado (`LEGACY_SERVICE` = `es.egdev.ai-usage`) con + /// una clave, y verifica que `get()` la recupera POR LA VÍA DE MIGRACIÓN + /// — se lee una vez, se reescribe en el destino actual y desaparece del + /// legado (criterio AC2 del owner, issue #106). Sin imprimir el valor: + /// solo asserts. SOLO corre en el entorno aislado del CI (mismo paso de + /// linux-ci.yml que el round trip); exige QUORB_SECRETS_SMOKE_ISOLATED=1 + /// y falla antes de tocar nada si no está definida — nunca en un + /// escritorio con llavero real. + #[cfg(target_os = "linux")] + #[test] + #[ignore = "requiere llavero aislado del CI (QUORB_SECRETS_SMOKE_ISOLATED=1)"] + fn linux_legacy_migrate_once_then_legacy_is_gone() { + // AISLAMIENTO OBLIGATORIO (revisión del owner, 2026-10-28): este smoke + // escribe en la clave real `nan_session` del llavero. Solo debe correr + // en el entorno aislado del CI (dbus-run-session + gnome-keyring + // efímero, sin datos reales). El workflow define + // QUORB_SECRETS_SMOKE_ISOLATED=1 dentro de esa sesión; si no está, + // el smoke FALLA ANTES de tocar nada — nunca un escritorio real. + assert!( + std::env::var("QUORB_SECRETS_SMOKE_ISOLATED") + .map(|v| v == "1") + .unwrap_or(false), + "smoke de migración legacy solo en llavero aislado del CI (QUORB_SECRETS_SMOKE_ISOLATED=1); no lo ejecutes en un escritorio con datos reales" + ); + // Clave BASE de la lista cerrada (nan_session, sin `@`): la migración + // legacy solo aplica a claves sin sufijo de cuenta. + struct CleanupGuard(String); + impl Drop for CleanupGuard { + fn drop(&mut self) { + // Limpiar lo que haya quedado en cualquiera de los dos + // servicios (actual y legado), sin pasar por `get` (que + // migraría de nuevo). Solo corre en el entorno aislado del + // CI (la var de aislamiento ya se exigió arriba), así que + // nunca borra datos reales de un escritorio. + let _ = set(&self.0, ""); + if let Ok(legacy) = keyring::Entry::new(LEGACY_SERVICE, &self.0) { + let _ = legacy.delete_credential(); + } + } + } + let key = NAN_SESSION.to_string(); + // Nota: la migración legacy SOLO aplica a claves BASE (sin `@`): + // `get()` retorna `current` sin migrar si la clave lleva sufijo de + // cuenta (`key.contains('@')`). Por eso el smoke usa una clave base + // de la lista cerrada (nan_session), no una `scoped`. + assert!( + !key.contains('@'), + "la clave de migración debe ser base, sin sufijo de cuenta" + ); + assert!( + ALL.contains(&key.as_str()), + "la clave debe ser de la lista cerrada (entry la acepta)" + ); + // El guard se crea ANTES de cualquier lectura (revisión del owner): + // así, si algo falla a mitad, la limpieza ya está registrada. La + // precondición del destino actual vacío se comprueba vía entry() + // (no get(): None no distingue ausencia de valor de error de lectura). + let _guard = CleanupGuard(key.clone()); + // Precondición del destino actual vacío, distinguiendo error de + // lectura (NoEntry => vacío; cualquier otro error => fallo explícito, + // no un falso "vacío"). + let current_probe = entry(&key) + .expect("entry del destino actual") + .get_password(); + let empty_current = match current_probe { + Ok(v) => v.trim().is_empty(), + Err(keyring::Error::NoEntry) => true, + Err(err) => panic!("error de lectura del destino actual en llavero aislado: {err}"), + }; + assert!( + empty_current, + "el destino actual debería estar vacío en el llavero aislado (nan_session)" + ); + // Poblar el legado DIRECTAMENTE (sin pasar por set/get, que migrarían + // o escribirían en el destino actual): el escenario es un secreto que + // quedó en el servicio anterior cuando se cambió el identificador. + let value = "valor-de-prueba-del-smoke-de-migracion"; // gitleaks:allow + assert!( + crate::channel::current().migrate_legacy, + "el perfil estable debe tener la migración activa" + ); + let legacy = keyring::Entry::new(LEGACY_SERVICE, &key).expect("entry legado"); + legacy + .set_password(value) + .expect("set en el servicio legado"); // gitleaks:allow + // get() con el destino actual vacío debe recuperarlo POR MIGRACIÓN: + // lee el legado, lo reescribe en el actual y borra el original. + assert_eq!( + get(&key).as_deref(), + Some(value), + "get debe migrar el valor legado al destino actual" + ); // gitleaks:allow + // Reescribir en el destino actual: comprobar sin volver a migrar. + let current = + keyring::Entry::new(crate::channel::current().identifier, &key).expect("entry actual"); + assert_eq!( + current.get_password().ok().as_deref(), + Some(value), + "el valor debe quedar en el destino actual" + ); // gitleaks:allow + // Y desaparecer de futuras lecturas directas al legado. + assert!( + matches!(legacy.get_password(), Err(keyring::Error::NoEntry)), + "el legado debe desaparecer tras la migración" + ); + } } From 170db19e35aedd7b07fd0fb950e158639e4ef8a2 Mon Sep 17 00:00:00 2001 From: jose luis <2064537+sosan@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:51:41 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(secrets):=20aplicar=20fixes=20del=20rus?= =?UTF-8?q?t-review=20al=20smoke=20de=20migraci=C3=B3n=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revisión del agente rust-reviewer (2026-10-28), sin CRITICAL/HIGH: - MEDIUM: el Drop del CleanupGuard re-verifica QUORB_SECRETS_SMOKE_ISOLATED antes de borrar; si la precondición de aislamiento falló (entorno con llavero real donde la var se filtró y el destino NO estaba vacío), no borra nada. El comentario deja claro que la protección real es el keyring efímero del CI, no el assert. - LOW: el round trip (linux_secret_service_round_trip) ahora exige el mismo QUORB_SECRETS_SMOKE_ISOLATED=1 antes de tocar nada (coherencia doc/código). - LOW: doc del smoke de migración ajustado — el valor no se loguea en el camino feliz (aparece solo en un assert_eq! si algo falla, y es una constante de prueba no secreta). Co-Authored-By: Claude Code --- src-tauri/src/secrets.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index e898630..ca3127d 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -272,6 +272,15 @@ mod tests { #[test] #[ignore = "requiere llavero aislado del CI"] fn linux_secret_service_round_trip() { + // Aislamiento igual que el smoke de migración (coherencia doc/código, + // revisión rust-reviewer): la única vía soportada es el keyring + // efímero del CI; sin QUORB_SECRETS_SMOKE_ISOLATED=1 no toca nada. + assert!( + std::env::var("QUORB_SECRETS_SMOKE_ISOLATED") + .map(|v| v == "1") + .unwrap_or(false), + "smoke de llavero solo en entorno aislado del CI (QUORB_SECRETS_SMOKE_ISOLATED=1); no lo ejecutes en un escritorio con datos reales" + ); // Clave única por corrida y propia de la lista cerrada: `entry` rechaza // cualquier clave ajena, así que el smoke usa la misma vía que la app. struct CleanupGuard(String); @@ -316,8 +325,10 @@ mod tests { /// pobla el espacio legado (`LEGACY_SERVICE` = `es.egdev.ai-usage`) con /// una clave, y verifica que `get()` la recupera POR LA VÍA DE MIGRACIÓN /// — se lee una vez, se reescribe en el destino actual y desaparece del - /// legado (criterio AC2 del owner, issue #106). Sin imprimir el valor: - /// solo asserts. SOLO corre en el entorno aislado del CI (mismo paso de + /// legado (criterio AC2 del owner, issue #106). El valor no se loguea en + /// el camino feliz (solo aparece en un assert_eq! si algo falla, y es una + /// constante de prueba no secreta). SOLO corre en el entorno aislado del + /// CI (mismo paso de /// linux-ci.yml que el round trip); exige QUORB_SECRETS_SMOKE_ISOLATED=1 /// y falla antes de tocar nada si no está definida — nunca en un /// escritorio con llavero real. @@ -342,11 +353,16 @@ mod tests { struct CleanupGuard(String); impl Drop for CleanupGuard { fn drop(&mut self) { - // Limpiar lo que haya quedado en cualquiera de los dos - // servicios (actual y legado), sin pasar por `get` (que - // migraría de nuevo). Solo corre en el entorno aislado del - // CI (la var de aislamiento ya se exigió arriba), así que - // nunca borra datos reales de un escritorio. + // Defensa en profundidad (revisión rust-reviewer, 2026-10-28): + // si este Drop se ejecuta tras fallar la precondición de + // aislamiento (p. ej. un entorno con llavero real donde la + // var se filtró y el destino NO estaba vacío), NO debe borrar + // nada: re-verificar la var antes de limpiar. La protección + // real es el keyring efímero del CI; la var es la señal del + // contrato, y este guard no borra sin ella. + if std::env::var("QUORB_SECRETS_SMOKE_ISOLATED").as_deref() != Ok("1") { + return; + } let _ = set(&self.0, ""); if let Ok(legacy) = keyring::Entry::new(LEGACY_SERVICE, &self.0) { let _ = legacy.delete_credential(); From 06131e669520f6b077c8d55bca12e6de4c021dc0 Mon Sep 17 00:00:00 2001 From: jose luis <2064537+sosan@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:12:51 +0100 Subject: [PATCH 3/3] fix(docs): regenerar inventario i18n tras rebase sobre main (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El inventario no se edita a mano: se regenera. Su header declara el hash del árbol `src-tauri/src/`, que cambia con el smoke de migracion de secrets. Regenerado y verificado con `node scripts/i18n-inventory.mjs --check`. Co-Authored-By: Claude Code --- docs/i18n-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index d940f2b..9ddbad4 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `71fa2ce6c1ffbdf5a1f3fec0925dfed7a6a505ed`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `baa9e76200573af19bb476eda25b3095f89e3df6`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`.