Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .github/workflows/linux-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,23 +107,34 @@ 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)..."
dbus-run-session -- bash -euo pipefail -c '
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:-<none>}"
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 ----
Expand Down
2 changes: 1 addition & 1 deletion docs/i18n-inventory.md
Original file line number Diff line number Diff line change
@@ -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 <commit>:src` and `<commit>:src-tauri/src` match has these exact texts.
Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`.

Expand Down
35 changes: 27 additions & 8 deletions docs/linux-l1-evidence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
139 changes: 132 additions & 7 deletions src-tauri/src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"
);
}
}
Loading