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/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`. 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..ca3127d 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -261,16 +261,26 @@ 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() { + // 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); @@ -310,4 +320,119 @@ 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). 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. + #[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) { + // 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(); + } + } + } + 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" + ); + } }