diff --git a/rfirma-app/src-tauri/src/crossing/guards/tests.rs b/rfirma-app/src-tauri/src/crossing/guards/tests.rs index 3f576938..dd53d7dd 100644 --- a/rfirma-app/src-tauri/src/crossing/guards/tests.rs +++ b/rfirma-app/src-tauri/src/crossing/guards/tests.rs @@ -212,7 +212,7 @@ fn the_list_of_commands_is_closed_and_this_is_how_long_it_is() { .map(|(_, source)| production_half(source).matches("#[tauri::command").count()) .sum(); - assert_eq!(orders, 66, "la lista de ordenes es cerrada a proposito"); + assert_eq!(orders, 67, "la lista de ordenes es cerrada a proposito"); } #[test] diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs index 96e2d0d4..1dba9e6a 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs @@ -57,6 +57,7 @@ struct Login { shown_attempts: Option, depth: usize, cut: Option, + last_read: Option<(Store, Vec)>, } impl DnieToken { @@ -133,30 +134,61 @@ impl DnieToken { .map_err(|error| token_error(&error)) } - fn read( - &self, - store: &Store, - with_the_intermediate: bool, - ) -> Result, TokenError> { - let mut session = match self.opener.open() { - Ok(session) => session, - Err(error) if is_no_readable_dnie(&error) => return Ok(Vec::new()), - Err(error) => return Err(token_error(&error)), + /// Titulares y CA intermedia en una sola sesión; todo lo leído se queda para la cadena del mismo listado. + fn read_holders(&self, store: &Store) -> Result, TokenError> { + let Some(mut session) = self.open_if_readable()? else { + return Ok(Vec::new()); }; - let mut found: Vec = session - .certificates() - .map_err(|error| token_error(&error))? - .into_iter() - .map(|certificate| in_the_store(store, &certificate.label, certificate.der)) - .collect(); - if with_the_intermediate { - let intermediate = session - .certificate(INTERMEDIATE_CA_LABEL) - .map_err(|error| token_error(&error))?; - found.extend(intermediate.map(|der| in_the_store(store, INTERMEDIATE_CA_LABEL, der))); + let holders = holders_of(store, &mut session)?; + let intermediate = session + .certificate(INTERMEDIATE_CA_LABEL) + .ok() + .flatten() + .map(|der| in_the_store(store, INTERMEDIATE_CA_LABEL, der)); + self.login().last_read = intermediate.map(|intermediate| { + let mut every = holders.clone(); + every.push(intermediate); + (store.clone(), every) + }); + Ok(holders) + } + + fn read_every_certificate(&self, store: &Store) -> Result, TokenError> { + if let Some((kept, every)) = self.login().last_read.take() { + if &kept == store { + return Ok(every); + } } + let Some(mut session) = self.open_if_readable()? else { + return Ok(Vec::new()); + }; + let mut found = holders_of(store, &mut session)?; + let intermediate = session + .certificate(INTERMEDIATE_CA_LABEL) + .map_err(|error| token_error(&error))?; + found.extend(intermediate.map(|der| in_the_store(store, INTERMEDIATE_CA_LABEL, der))); Ok(found) } + + fn open_if_readable(&self) -> Result>, TokenError> { + match self.opener.open() { + Ok(session) => Ok(Some(session)), + Err(error) if is_no_readable_dnie(&error) => Ok(None), + Err(error) => Err(token_error(&error)), + } + } +} + +fn holders_of( + store: &Store, + session: &mut Session, +) -> Result, TokenError> { + Ok(session + .certificates() + .map_err(|error| token_error(&error))? + .into_iter() + .map(|certificate| in_the_store(store, &certificate.label, certificate.der)) + .collect()) } /// La versión de rdnie que fija `Cargo.lock`. @@ -264,11 +296,11 @@ fn has_a_length_the_dnie_admits(secret: &ProtectedSecret) -> bool { impl Token for DnieToken { fn list(&self, store: &Store) -> Result, TokenError> { - self.read(store, false) + self.read_holders(store) } fn every_certificate(&self, store: &Store) -> Result, TokenError> { - self.read(store, true) + self.read_every_certificate(store) } fn list_authenticated( diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs index 6a220fde..2925128f 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs @@ -1,3 +1,6 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + use openssl::asn1::Asn1Time; use openssl::bn::BigNum; use openssl::hash::MessageDigest; @@ -250,6 +253,41 @@ fn the_intermediate_ca_completes_the_chain_of_both_certificates_for_a_site() { } } +#[test] +fn a_full_listing_with_holders_and_chain_opens_the_card_once() { + for (version, card) in the_two_versions() { + let police = OfThePolice::new(); + let opened = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&opened); + let dnie = the_dnie(move || { + counter.fetch_add(1, Ordering::SeqCst); + police.on(card()) + }); + + let offered = certificates_with_their_chains(&dnie, &[dnie_store()]) + .expect("el DNIe deberia listarse"); + + assert_eq!(offered.len(), 2, "{version}"); + assert_eq!(opened.load(Ordering::SeqCst), 1, "{version}"); + } +} + +#[test] +fn the_chain_of_a_later_listing_is_read_from_the_card_again() { + let police = OfThePolice::new(); + let opened = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&opened); + let dnie = the_dnie(move || { + counter.fetch_add(1, Ordering::SeqCst); + police.on(EmulatedDnie::dnie_3()) + }); + + certificates_with_their_chains(&dnie, &[dnie_store()]).expect("el DNIe deberia listarse"); + certificates_with_their_chains(&dnie, &[dnie_store()]).expect("el DNIe deberia listarse"); + + assert_eq!(opened.load(Ordering::SeqCst), 2); +} + #[test] fn the_signing_panel_offers_only_the_signing_certificate_of_the_dnie() { for (version, card) in the_two_versions() { diff --git a/rfirma-app/src-tauri/src/identity/adapters/tauri.rs b/rfirma-app/src-tauri/src/identity/adapters/tauri.rs index df94039f..78f4b3cc 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/tauri.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/tauri.rs @@ -53,6 +53,19 @@ pub fn list_certificates( .collect()) } +/// Al recuperar el foco la ventana principal: lista solo los almacenes que no son de tarjeta (ADR-0048). +#[tauri::command(async)] +pub fn list_certificates_other_than_cards( + identity: State<'_, IdentityRoot>, +) -> Result, Failure> { + Ok(identity + .card_listing() + .relisted_other_than_cards()? + .into_iter() + .map(CertificateView::from) + .collect()) +} + /// El último estado anunciado de los lectores, para la ventana que se monta después del anuncio (ADR-0048). #[tauri::command] pub fn read_card_readers(identity: State<'_, IdentityRoot>) -> ReaderStatusView { @@ -116,8 +129,11 @@ pub fn remove_certificate(id: String, identity: State<'_, IdentityRoot>) -> Resu identity.memory.as_ref(), identity.installed_certificates(), &id, - &identity.listed, - &identity.installed_copies, + crate::identity::application::certificates::RemovalListing { + listed: &identity.listed, + installed_copies: &identity.installed_copies, + last: &identity.last_listing, + }, )?, ) } diff --git a/rfirma-app/src-tauri/src/identity/application/certificates.rs b/rfirma-app/src-tauri/src/identity/application/certificates.rs index 75573f3f..288a8931 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::path::Path; use crate::documents::domain::handles::Handles; -use crate::identity::application::readers::LastListing; +use crate::identity::application::readers::{rows_marking_the_installed, LastListing}; use crate::identity::domain::certificate::{CertificateRef, ListedCertificate, TokenCertificate}; use crate::identity::domain::chain::issuers_of; use crate::identity::domain::copies::{copies_of_each_certificate, ChosenCopy}; @@ -60,24 +60,16 @@ pub fn listed_rows( memory: &dyn CertificateMemory, last: &LastListing, ) -> Result, TokenError> { - let found = token.list_across(stores)?; + let found = with_their_chains(token, token.list_across(stores)?); last.keep(&found); - Ok(rows_keeping_handles( - on_the_desktop(found), + Ok(rows_marking_the_installed( + token, + found, installed_dir, listed, installed_copies, memory, - ) - .into_iter() - .map(|mut row| { - row.installed = row.installed - || listed - .get(&row.id) - .is_some_and(|reference| token.installed_in_the_system(installed_dir, &reference)); - row - }) - .collect()) + )) } /// Si rFirma instaló `reference` desde un `.p12`: en su Almacén o, donde instala el sistema, apuntado por el token. @@ -95,10 +87,17 @@ pub fn certificates_with_their_chains( token: &dyn Token, stores: &[Store], ) -> Result, TokenError> { - let found = holders_only(token.list_across(stores)?); + Ok(with_their_chains( + token, + holders_only(token.list_across(stores)?), + )) +} + +/// Cada certificado con los emisores que su propio almacén aporta. +pub fn with_their_chains(token: &dyn Token, found: Vec) -> Vec { let mut neighbours: HashMap> = HashMap::new(); - Ok(found + found .into_iter() .map(|certificate| { let store = certificate.reference().store(); @@ -108,7 +107,7 @@ pub fn certificates_with_their_chains( let issuers = issuers_of(&certificate, in_the_store); certificate.with_its_issuers(issuers) }) - .collect()) + .collect() } /// Lo que se enseña en el escritorio: sin autoridades ni el de autenticación del DNIe, como AutoFirma. @@ -413,6 +412,16 @@ fn only_supported_keys( Ok(()) } +/// El último listado donde se busca el asa que se quita y del que se borra. +pub struct RemovalListing<'a> { + /// Las filas del último listado. + pub listed: &'a ListedCertificates, + /// La copia instalada de cada fila que tenga una. + pub installed_copies: &'a ListedCertificates, + /// El último listado completo. + pub last: &'a LastListing, +} + /// Quita un certificado del Almacén de rFirma: lo borra, con su clave, de la base única (ADR-0034). pub fn remove_installed( token: &dyn Token, @@ -420,12 +429,14 @@ pub fn remove_installed( memory: &dyn CertificateMemory, installed_dir: &Path, handle: &str, - listed: &ListedCertificates, - installed_copies: &ListedCertificates, + listing: RemovalListing<'_>, ) -> Result<(), InstallError> { - let reference = match installed_copies.get(handle) { + let reference = match listing.installed_copies.get(handle) { Some(installed) => installed, - None => listed.get(handle).ok_or_else(not_from_the_last_listing)?, + None => listing + .listed + .get(handle) + .ok_or_else(not_from_the_last_listing)?, }; if !installed_by_rfirma(token, installed_dir, &reference) { return Err(TokenError::new( @@ -440,6 +451,7 @@ pub fn remove_installed( keyring.pin()? }; token.remove_certificate(installed_dir, &reference, &pin)?; + listing.last.forget(&reference); if memory.remembered_certificate().as_ref() == Some(&reference) { let _ = memory.forget_the_certificate(); } diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs index 620c888a..e3e93e0d 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs @@ -2,6 +2,7 @@ use std::path::Path; use super::super::{ certificate_behind, remember_the_certificate, remove_installed, rows_of, ListedCertificates, + RemovalListing, }; use super::removal::{FixedPinKeyring, RemovalOutcome}; use super::{CARD, INSTALLED, SOFTOKEN}; @@ -336,8 +337,11 @@ fn a_row_signed_with_a_remembered_browser_copy_still_removes_its_installed_copy( &memory, &installed_dir, &rows[0].id, - &listed, - &installed_copies, + RemovalListing { + listed: &listed, + installed_copies: &installed_copies, + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect("la copia instalada deberia poder quitarse aunque la elegida sea de firefox"); } diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/removal.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/removal.rs index b7797a2c..7492c1cf 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/removal.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/removal.rs @@ -1,6 +1,8 @@ use std::path::Path; -use super::super::{remember_the_certificate, remove_installed, InstallError, ListedCertificates}; +use super::super::{ + remember_the_certificate, remove_installed, InstallError, ListedCertificates, RemovalListing, +}; use crate::identity::application::tests::a_certificate; use crate::identity::domain::algorithm::SignatureAlgorithm; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; @@ -132,8 +134,11 @@ fn removing_refuses_a_handle_that_is_not_from_the_last_listing() { &a_memory(home.path()), &home.path().join("certificates"), "00000000000000000000000000000000", - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect_err("no es de la ultima busqueda"); @@ -156,8 +161,11 @@ fn removing_refuses_a_certificate_from_somewhere_else() { &a_memory(home.path()), &home.path().join("certificates"), &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect_err("no viene de un .p12 instalado"); @@ -181,8 +189,11 @@ fn removing_asks_the_keyring_for_the_pin_and_propagates_its_refusal() { &a_memory(home.path()), &installed, &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect_err("sin PIN todavia no hay nada que quitar"); @@ -207,8 +218,11 @@ fn removing_propagates_the_token_refusal() { &a_memory(home.path()), &installed, &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect_err("el token se ha negado"); @@ -234,8 +248,11 @@ fn removing_the_remembered_certificate_forgets_it() { &memory, &installed, &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect("deberia quitarse"); @@ -263,10 +280,40 @@ fn removing_a_different_certificate_leaves_the_remembered_one_alone() { &memory, &installed, &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect("deberia quitarse"); assert_eq!(memory.remembered_certificate(), Some(remembered)); } + +#[test] +fn a_removed_p12_leaves_the_last_listing_at_once() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let installed = home.path().join("certificates"); + let reference = an_installed_reference(&installed, 0x01); + let last = crate::identity::application::readers::LastListing::default(); + last.keep(&[TokenCertificate::new(reference.clone(), Vec::new())]); + let listed = ListedCertificates::new(); + let handle = listed.mint(reference); + + remove_installed( + &RemovalOutcome(Ok(())), + &FixedPinKeyring("1234"), + &a_memory(home.path()), + &installed, + &handle, + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &last, + }, + ) + .expect("deberia quitarse"); + + assert_eq!(last.found().map(|found| found.len()), Some(0)); +} diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs index 237b3dfd..0610a3e2 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs @@ -1,7 +1,9 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use super::super::{install_pkcs12, remove_installed, InstallError, ListedCertificates}; +use super::super::{ + install_pkcs12, remove_installed, InstallError, ListedCertificates, RemovalListing, +}; use crate::identity::application::tests::NoToken; use crate::identity::domain::algorithm::SignatureAlgorithm; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; @@ -219,8 +221,11 @@ fn removing_a_macos_store_certificate_asks_the_system_and_forgets_it() { &memory, &home.path().join("certificates"), &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ); assert_eq!(removed, Ok(())); @@ -241,8 +246,11 @@ fn a_macos_store_certificate_rfirma_did_not_install_is_not_removed() { &a_memory(home.path()), &home.path().join("certificates"), &handle, - &listed, - &ListedCertificates::new(), + RemovalListing { + listed: &listed, + installed_copies: &ListedCertificates::new(), + last: &crate::identity::application::readers::LastListing::default(), + }, ) .expect_err("no lo instalo rFirma"); diff --git a/rfirma-app/src-tauri/src/identity/application/readers.rs b/rfirma-app/src-tauri/src/identity/application/readers.rs index 2f314b6a..d3b21d18 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers.rs @@ -7,9 +7,11 @@ use std::thread::JoinHandle; use crate::desktop::domain::channel::Channel; use crate::desktop::domain::platform::Platform; use crate::identity::application::certificates::{ - on_the_desktop, rows_keeping_handles, ListedCertificates, + listed_rows, on_the_desktop, rows_keeping_handles, usable_certificate, with_their_chains, + ListedCertificates, }; use crate::identity::domain::certificate::{CertificateRef, ListedCertificate, TokenCertificate}; +use crate::identity::domain::error::TokenError; use crate::identity::domain::readers::{ ready_card_among, status_of, Listing, Reader, ReaderStatus, ReadyCard, }; @@ -28,6 +30,32 @@ impl LastListing { *lock(&self.found) = Some(found.to_vec()); } + /// El último listado completo, si ya hubo alguno. + pub fn found(&self) -> Option> { + lock(&self.found).clone() + } + + /// Saca del último listado el certificado que ya no está, sin abrir ningún almacén. + pub fn forget(&self, reference: &CertificateRef) { + if let Some(found) = lock(&self.found).as_mut() { + found.retain(|certificate| certificate.reference() != reference); + } + } + + /// Guarda lo de los almacenes que no son de tarjeta junto con las tarjetas que tenga ahora, y lo devuelve. + fn replace_other_than_cards( + &self, + mut found: Vec, + installed_dir: &Path, + ) -> Vec { + let mut last = lock(&self.found); + if let Some(before) = last.as_deref() { + found.extend(only_on_a_card(before, installed_dir)); + } + *last = Some(found.clone()); + found + } + /// Los certificados del último listado de los almacenes que no enseñan tarjetas, si ya hubo alguno. fn other_than_cards(&self, installed_dir: &Path) -> Option> { lock(&self.found).as_ref().map(|found| { @@ -40,6 +68,52 @@ impl LastListing { } } +/// El certificado del asa, tomado del último listado; solo si no hay ninguno lista los almacenes y lo guarda (ADR-0011). +pub fn chosen_from_the_last_listing( + token: &dyn Token, + stores: &[Store], + last: &LastListing, + listed: &ListedCertificates, + handle: &str, +) -> Result { + let found = match last.found() { + Some(found) => found, + None => { + let found = with_their_chains(token, token.list_across(stores)?); + last.keep(&found); + found + } + }; + usable_certificate(&found, handle, listed).cloned() +} + +/// Las filas del escritorio de lo encontrado, con las asas conservadas y las instaladas marcadas. +pub fn rows_marking_the_installed( + token: &dyn Token, + found: Vec, + installed_dir: &Path, + listed: &ListedCertificates, + installed_copies: &ListedCertificates, + memory: &dyn CertificateMemory, +) -> Vec { + rows_keeping_handles( + on_the_desktop(found), + installed_dir, + listed, + installed_copies, + memory, + ) + .into_iter() + .map(|mut row| { + row.installed = row.installed + || listed + .get(&row.id) + .is_some_and(|reference| token.installed_in_the_system(installed_dir, &reference)); + row + }) + .collect() +} + /// Si esta plataforma y este canal pueden vigilar lectores (ADR-0048). pub fn watches_the_readers(platform: Platform, channel: Channel) -> bool { match platform { @@ -102,6 +176,45 @@ pub struct CardListing<'a> { pub last: &'a LastListing, } +impl CardListing<'_> { + /// Al recuperar el foco: abre solo los almacenes que no son de tarjeta y toma el resto del último listado. + pub fn relisted_other_than_cards(&self) -> Result, TokenError> { + if self.last.found().is_none() { + return listed_rows( + self.token, + &self.stores, + self.installed_dir, + self.listed, + self.installed_copies, + self.memory, + self.last, + ); + } + let others: Vec = self + .stores + .iter() + .filter(|store| store.class_under(self.installed_dir) != StoreClass::Card) + .cloned() + .collect(); + let found = if others.is_empty() { + Vec::new() + } else { + with_their_chains(self.token, self.token.list_across(&others)?) + }; + let found = self + .last + .replace_other_than_cards(found, self.installed_dir); + Ok(rows_marking_the_installed( + self.token, + found, + self.installed_dir, + self.listed, + self.installed_copies, + self.memory, + )) + } +} + impl Relisting for CardListing<'_> { fn relisted(&self) -> (Option>, Option) { let (cards, others): (Vec, Vec) = self @@ -187,7 +300,7 @@ fn listed_in(token: &dyn Token, stores: &[Store]) -> Vec { if stores.is_empty() { return Vec::new(); } - token.list_across(stores).unwrap_or_default() + with_their_chains(token, token.list_across(stores).unwrap_or_default()) } /// Si el almacén se vuelve a listar al cambiar los lectores: uno de tarjeta, o el de Windows o macOS, que copian las suyas. diff --git a/rfirma-app/src-tauri/src/identity/application/readers/tests/chosen.rs b/rfirma-app/src-tauri/src/identity/application/readers/tests/chosen.rs new file mode 100644 index 00000000..9dc2fdf1 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/application/readers/tests/chosen.rs @@ -0,0 +1,141 @@ +use super::*; + +pub(super) fn chosen_by( + arranged: &Arranged, + last: &LastListing, + handle: &str, +) -> Result { + chosen_from_the_last_listing( + &arranged.token, + &arranged.stores, + last, + &arranged.listed, + handle, + ) +} + +fn some_handle(arranged: &Arranged) -> String { + arranged.listed_in_full()[0].id.clone() +} + +#[test] +fn after_a_full_listing_the_chosen_certificate_is_resolved_without_opening_a_store() { + let arranged = Arranged::new(); + let handle = some_handle(&arranged); + arranged.token.asked(); + + chosen_by(&arranged, &arranged.last, &handle).expect("esta en el último listado"); + + assert_eq!(arranged.token.asked(), Vec::::new()); +} + +#[test] +fn with_no_listing_yet_the_chosen_certificate_lists_once_and_the_next_does_not() { + let arranged = Arranged::new(); + let handle = some_handle(&arranged); + let nothing_listed_yet = LastListing::default(); + arranged.token.asked(); + + chosen_by(&arranged, ¬hing_listed_yet, &handle).expect("lista y lo encuentra"); + assert!(!arranged.token.asked().is_empty()); + + chosen_by(&arranged, ¬hing_listed_yet, &handle).expect("ya está guardado"); + assert_eq!(arranged.token.asked(), Vec::::new()); +} + +#[test] +fn a_card_that_came_in_through_the_readers_resolves_without_listing_and_fails_by_name_once_out() { + let (card, token) = WindowsWithACard::with_the_dnie(); + let arranged = Arranged::over(token, vec![the_windows_store()]); + let heard = followed(&arranged, &card, &[true]); + let handle = heard + .iter() + .filter_map(|news| news.certificates.as_ref()) + .flatten() + .find(|row| row.label.contains(ON_THE_DNIE)) + .expect("la tarjeta tiene fila") + .id + .clone(); + arranged.token.asked(); + + chosen_by(&arranged, &arranged.last, &handle).expect("entró por la lista en caliente"); + assert_eq!(arranged.token.asked(), Vec::::new()); + + followed(&arranged, &card, &[true, false]); + arranged.token.asked(); + let failure = chosen_by(&arranged, &arranged.last, &handle).expect_err("la tarjeta se fue"); + + assert_eq!(failure.situation(), Situation::CertificateNotFound); + assert_eq!(arranged.token.asked(), Vec::::new()); +} + +#[test] +fn a_forgotten_certificate_is_no_longer_resolved_and_nothing_is_listed() { + let arranged = Arranged::new(); + let handle = some_handle(&arranged); + let reference = arranged.listed.get(&handle).expect("asa"); + arranged.token.asked(); + + arranged.last.forget(&reference); + + let failure = chosen_by(&arranged, &arranged.last, &handle).expect_err("ya no está"); + assert_eq!(failure.situation(), Situation::CertificateNotFound); + assert_eq!(arranged.token.asked(), Vec::::new()); +} + +#[test] +fn a_card_put_in_while_the_focus_lists_is_still_resolved_afterwards() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Barrier}; + + let card_in = Arc::new(AtomicBool::new(false)); + let hold_the_nss = Arc::new(AtomicBool::new(false)); + let (inside, release) = (Arc::new(Barrier::new(2)), Arc::new(Barrier::new(2))); + let token = { + let (card_in, hold_the_nss) = (card_in.clone(), hold_the_nss.clone()); + let (inside, release) = (inside.clone(), release.clone()); + Counting::showing(move |store| { + if *store == Store::module(CARD) { + if !card_in.load(Ordering::SeqCst) { + return Vec::new(); + } + return vec![TokenCertificate::new( + CertificateRef::new(store.clone(), "token", "FIRMA", vec![0x02]), + TestAuthority::root("Firmante de la tarjeta").der(), + )]; + } else if hold_the_nss.swap(false, Ordering::SeqCst) { + inside.wait(); + release.wait(); + } + vec![a_certificate_in(store)] + }) + }; + let arranged = Arranged::over( + token, + vec![ + Store::module(CARD), + Store::nss( + SOFTOKEN, + Path::new("/casa/ada/.mozilla/firefox/ada.default"), + ), + ], + ); + arranged.listed_in_full(); + hold_the_nss.store(true, Ordering::SeqCst); + + let handle = std::thread::scope(|scope| { + let focus = scope.spawn(|| arranged.card_listing().relisted_other_than_cards()); + inside.wait(); + card_in.store(true, Ordering::SeqCst); + let (rows, _) = arranged.card_listing().relisted(); + release.wait(); + focus.join().unwrap().expect("deberia listar"); + rows.expect("la lista del lector") + .into_iter() + .find(|row| row.holder_name == "Firmante de la tarjeta") + .expect("la tarjeta tiene fila") + .id + }); + + chosen_by(&arranged, &arranged.last, &handle).expect("la tarjeta sigue en el último listado"); +} diff --git a/rfirma-app/src-tauri/src/identity/application/readers/tests/focus.rs b/rfirma-app/src-tauri/src/identity/application/readers/tests/focus.rs new file mode 100644 index 00000000..89ffae8b --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/application/readers/tests/focus.rs @@ -0,0 +1,57 @@ +use super::chosen::chosen_by; +use super::*; + +#[test] +fn regaining_the_focus_opens_only_the_stores_that_are_not_cards_and_keeps_handles_and_listing() { + let arranged = Arranged::new(); + let before = arranged.listed_in_full(); + arranged.token.asked(); + + let rows = arranged + .card_listing() + .relisted_other_than_cards() + .expect("deberia listar"); + + assert_eq!( + arranged.token.asked(), + vec![Store::nss( + SOFTOKEN, + Path::new("/casa/ada/.mozilla/firefox/ada.default"), + )] + ); + let mut ids: Vec<_> = rows.iter().map(|row| row.id.clone()).collect(); + let mut kept: Vec<_> = before.iter().map(|row| row.id.clone()).collect(); + ids.sort(); + kept.sort(); + assert_eq!(ids, kept); + chosen_by(&arranged, &arranged.last, &kept[0]).expect("sigue en el último listado"); + assert_eq!(arranged.token.asked(), Vec::::new()); +} + +#[test] +fn regaining_the_focus_lists_the_windows_store_with_the_others() { + let (_, token) = WindowsWithACard::with_the_dnie(); + let arranged = Arranged::over(token, vec![the_windows_store()]); + arranged.listed_in_full(); + arranged.token.asked(); + + arranged + .card_listing() + .relisted_other_than_cards() + .expect("deberia listar"); + + assert_eq!(arranged.token.asked(), vec![the_windows_store()]); +} + +#[test] +fn regaining_the_focus_with_no_listing_yet_lists_every_store() { + let arranged = Arranged::new(); + + let rows = arranged + .card_listing() + .relisted_other_than_cards() + .expect("deberia listar"); + + assert_eq!(arranged.token.asked().len(), 2); + assert_eq!(rows.len(), 2); +} diff --git a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs b/rfirma-app/src-tauri/src/identity/application/readers/tests/mod.rs similarity index 99% rename from rfirma-app/src-tauri/src/identity/application/readers/tests.rs rename to rfirma-app/src-tauri/src/identity/application/readers/tests/mod.rs index 5c2955f4..c29f2706 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers/tests/mod.rs @@ -6,6 +6,7 @@ use crate::identity::application::certificates::listed_rows; use crate::identity::application::tests::{NoMemory, NoToken, TestAuthority}; use crate::identity::domain::algorithm::SignatureAlgorithm; use crate::identity::domain::certificate::CertificateRef; +use crate::identity::domain::error::Situation; use crate::identity::domain::error::TokenError; use crate::identity::domain::protected_secret::ProtectedSecret; use crate::identity::domain::secret::StoreSecret; @@ -550,3 +551,6 @@ fn another_card_on_macos_that_no_named_module_reads_is_unreadable() { vec![ReaderStatus::Reading, ReaderStatus::Unreadable] ); } + +mod chosen; +mod focus; diff --git a/rfirma-app/src-tauri/src/identity/mod.rs b/rfirma-app/src-tauri/src/identity/mod.rs index 21e1305a..67c08c4f 100644 --- a/rfirma-app/src-tauri/src/identity/mod.rs +++ b/rfirma-app/src-tauri/src/identity/mod.rs @@ -120,10 +120,15 @@ impl IdentityRoot { application::certificates::usable_certificate(found, handle, &self.listed) } - /// El certificado del asa, listado de nuevo y comprobado antes de usarlo. + /// El certificado del asa, tomado del último listado y comprobado antes de usarlo. pub fn chosen(&self, handle: &str) -> Result { - let found = self.certificates()?; - self.usable(&found, handle).cloned() + application::readers::chosen_from_the_last_listing( + self.token.as_ref(), + &self.all_stores(), + &self.last_listing, + &self.listed, + handle, + ) } /// Apunta el certificado con el que se acaba de firmar. diff --git a/rfirma-app/src-tauri/src/identity/tests.rs b/rfirma-app/src-tauri/src/identity/tests.rs index b41e1275..916b641e 100644 --- a/rfirma-app/src-tauri/src/identity/tests.rs +++ b/rfirma-app/src-tauri/src/identity/tests.rs @@ -8,10 +8,12 @@ use crate::identity::domain::keyring::KeyringError; use crate::identity::domain::secret::StoreSecret; use crate::signing::ports::Signer; -/// Un token que solo recuerda con qué secreto se le pidió firmar o aceptar, y responde `reply` a `secret_of`. +/// Un token que enseña `shows`, cuenta los listados, recuerda con qué secreto se le pidió firmar o aceptar y responde `reply` a `secret_of`. struct RecordingToken { reply: StoreSecret, received_secret: Mutex>>, + shows: Vec, + lists: Arc, } impl RecordingToken { @@ -19,6 +21,15 @@ impl RecordingToken { Self { reply, received_secret: Mutex::new(None), + shows: Vec::new(), + lists: Arc::new(AtomicUsize::new(0)), + } + } + + fn showing(shows: Vec) -> Self { + Self { + shows, + ..Self::replying(StoreSecret::TypedOnScreen) } } @@ -29,7 +40,8 @@ impl RecordingToken { impl Token for RecordingToken { fn list(&self, _store: &Store) -> Result, TokenError> { - Ok(Vec::new()) + self.lists.fetch_add(1, Ordering::SeqCst); + Ok(self.shows.clone()) } fn every_certificate(&self, _store: &Store) -> Result, TokenError> { @@ -316,3 +328,56 @@ fn accepting_the_secret_of_an_installed_certificate_also_uses_the_keyring_pin() assert_eq!(token.received_secret(), Some(b"pin-del-llavero".to_vec())); assert_eq!(calls.load(Ordering::SeqCst), 1); } + +/// Un diálogo que ninguna de estas pruebas debería abrir. +struct UnreachablePrompter; + +impl SecretPrompter for UnreachablePrompter { + fn prompt_secret( + &self, + _request: &ports::SecretPromptRequest, + ) -> Result { + panic!("esta prueba no deberia pedir ningun secreto") + } +} + +#[test] +fn after_a_full_listing_the_chosen_certificate_opens_no_store() { + let installed = tempfile::tempdir().expect("directorio temporal"); + let token = RecordingToken::showing(vec![crate::identity::application::tests::a_certificate( + "FIRMA", + &crate::identity::application::tests::TestAuthority::root("Firmante de pruebas").der(), + )]); + let lists = token.lists.clone(); + let (keyring, _) = CountingKeyring::with_pin("pin"); + let identity = IdentityRoot { + token: Box::new(token), + stores: vec![Store::module("/usr/lib/softhsm/libsofthsm2.so")], + installed_certificates: installed.path().to_path_buf(), + listed: ListedCertificates::new(), + installed_copies: ListedCertificates::new(), + last_listing: LastListing::default(), + reader_now: ReaderNow::default(), + memory: Arc::new(crate::identity::application::tests::NoMemory), + folder: Arc::new(adapters::folder::RealInstalledFolder), + prompter: Arc::new(UnreachablePrompter), + keyring: factory_of(keyring), + }; + let handle = application::certificates::listed_rows( + identity.token.as_ref(), + &identity.all_stores(), + identity.installed_certificates(), + &identity.listed, + &identity.installed_copies, + identity.memory.as_ref(), + &identity.last_listing, + ) + .expect("deberia listar")[0] + .id + .clone(); + lists.store(0, Ordering::SeqCst); + + identity.chosen(&handle).expect("esta en el ultimo listado"); + + assert_eq!(lists.load(Ordering::SeqCst), 0); +} diff --git a/rfirma-app/src-tauri/src/lib.rs b/rfirma-app/src-tauri/src/lib.rs index d1b0ade0..beb7720b 100644 --- a/rfirma-app/src-tauri/src/lib.rs +++ b/rfirma-app/src-tauri/src/lib.rs @@ -242,6 +242,7 @@ fn with_the_five_roots( }) .invoke_handler(tauri::generate_handler![ identity::adapters::tauri::list_certificates, + identity::adapters::tauri::list_certificates_other_than_cards, identity::adapters::tauri::read_card_readers, signing::adapters::tauri::begin_signing, signing::adapters::tauri::sign_with_pin, diff --git a/rfirma-app/src-tauri/tests/contract.snapshot b/rfirma-app/src-tauri/tests/contract.snapshot index 4e473f1b..dd34a4b7 100644 --- a/rfirma-app/src-tauri/tests/contract.snapshot +++ b/rfirma-app/src-tauri/tests/contract.snapshot @@ -31,6 +31,7 @@ ORDENES DE TAURI (/adapters/tauri*.rs) async open_opened_folder(document: String) -> Result<(), Failure> async open_signed_folder() -> Result<(), Failure> list_certificates() -> Result, Failure> + async list_certificates_other_than_cards() -> Result, Failure> read_card_readers() -> ReaderStatusView async install_certificate() -> Result async remove_certificate(id: String) -> Result<(), Failure> diff --git a/rfirma-app/src-tauri/tests/installed_p12_removal.rs b/rfirma-app/src-tauri/tests/installed_p12_removal.rs index 29b42258..a97e1d0d 100644 --- a/rfirma-app/src-tauri/tests/installed_p12_removal.rs +++ b/rfirma-app/src-tauri/tests/installed_p12_removal.rs @@ -146,8 +146,11 @@ fn remove( &NoMemory, installed, handle, - listed, - &ListedCertificates::new(), + certificates::RemovalListing { + listed, + installed_copies: &ListedCertificates::new(), + last: &rfirma_lib::identity::application::readers::LastListing::default(), + }, )?) } diff --git a/rfirma-app/src/signing/certificate.ts b/rfirma-app/src/signing/certificate.ts index d77e1912..dbf76360 100644 --- a/rfirma-app/src/signing/certificate.ts +++ b/rfirma-app/src/signing/certificate.ts @@ -246,6 +246,8 @@ export interface CertificateStore { * de curva elíptica. */ install(): Promise; + /** Vuelve a listar solo los almacenes que no son de tarjeta; las tarjetas las cubre el vigilante de lectores. */ + listOtherThanCards(): Promise; /** Quita un `.p12` instalado, por el asa de su fila. */ remove(id: string): Promise; /** Vacía el Almacén de rFirma entero, ya confirmado por la persona (ADR-0034). */ @@ -290,6 +292,7 @@ export interface ReaderNews { export function emptyCertificateStore(): CertificateStore { return { list: async () => [], + listOtherThanCards: async () => [], install: async () => false, remove: async () => {}, emptyStore: async () => {}, diff --git a/rfirma-app/src/signing/useCertificateListing.test.ts b/rfirma-app/src/signing/useCertificateListing.test.ts index 8dbaac0a..ffc685de 100644 --- a/rfirma-app/src/signing/useCertificateListing.test.ts +++ b/rfirma-app/src/signing/useCertificateListing.test.ts @@ -11,6 +11,7 @@ const onToken = anInstalledCertificate({ id: "token", stores: ["card"], installe function aStore(found: Certificate[], overrides: Partial = {}): CertificateStore { return { list: vi.fn(async () => [...found]), + listOtherThanCards: vi.fn(async () => [...found]), install: vi.fn(async () => false), remove: vi.fn(async () => {}), emptyStore: vi.fn(async () => {}), @@ -122,6 +123,79 @@ describe("useCertificateListing", () => { }); }); +describe("useCertificateListing · el foco", () => { + it("lists only the stores that are not cards when the window regains focus, without a full listing", async () => { + const outside = anInstalledCertificate({ id: "outside", stores: ["macos"], installed: false }); + const store = aStore([onToken], { listOtherThanCards: vi.fn(async () => [onToken, outside]) }); + const { result } = await listed(store); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + await waitFor(() => + expect(result.current.listing).toEqual({ + kind: "listed", + certificates: [onToken, outside], + }), + ); + expect(store.listOtherThanCards).toHaveBeenCalledTimes(1); + expect(store.list).toHaveBeenCalledTimes(1); + }); + + it("keeps the list it has when listing on focus fails", async () => { + const store = aStore([onToken], { + listOtherThanCards: async () => Promise.reject(new Error("sin llavero")), + }); + const { result } = await listed(store); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + expect(result.current.listing).toEqual({ kind: "listed", certificates: [onToken] }); + }); + + it("leaves the first search alone when the window regains focus before it answers", async () => { + let answer: (found: Certificate[]) => void = () => {}; + const store = aStore([], { + list: () => new Promise((resolve) => (answer = resolve)), + listOtherThanCards: vi.fn(async () => Promise.reject(new Error("sin llavero"))), + }); + const { result } = renderHook(() => useCertificateListing(store)); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + await act(async () => answer([onToken])); + + expect(result.current.listing).toEqual({ kind: "listed", certificates: [onToken] }); + expect(store.listOtherThanCards).not.toHaveBeenCalled(); + }); + + it("keeps what looking again brings when the window regains focus meanwhile", async () => { + let answer: (found: Certificate[]) => void = () => {}; + const store = aStore([onToken], { listOtherThanCards: vi.fn(async () => [onToken]) }); + const { result } = await listed(store); + store.list = () => new Promise((resolve) => (answer = resolve)); + + let looking = Promise.resolve(); + act(() => { + looking = result.current.lookAgain(); + }); + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + await act(async () => { + answer([onToken, dnie]); + await looking; + }); + + expect(result.current.listing).toEqual({ kind: "listed", certificates: [onToken, dnie] }); + expect(store.listOtherThanCards).not.toHaveBeenCalled(); + }); +}); + describe("useCertificateListing · los lectores", () => { it("is unavailable until the backend says what it sees", async () => { const { result } = await listed(announcingCertificateStore([onToken])); diff --git a/rfirma-app/src/signing/useCertificateListing.ts b/rfirma-app/src/signing/useCertificateListing.ts index 4804b74a..950eb897 100644 --- a/rfirma-app/src/signing/useCertificateListing.ts +++ b/rfirma-app/src/signing/useCertificateListing.ts @@ -22,15 +22,19 @@ export function useCertificateListing(store: CertificateStore) { const [listing, setListing] = useState({ kind: "loading" }); const [reader, setReader] = useState(UNAVAILABLE_READER); const latestListing = useRef(0); + const fullListingsUnderWay = useRef(0); const lookAgain = useCallback(async () => { const mine = ++latestListing.current; + fullListingsUnderWay.current++; setListing({ kind: "loading" }); try { const certificates = await store.list(); if (mine === latestListing.current) setListing({ kind: "listed", certificates }); } catch (thrown) { if (mine === latestListing.current) setListing({ kind: "failed", failure: classify(thrown) }); + } finally { + fullListingsUnderWay.current--; } }, [store]); @@ -49,6 +53,21 @@ export function useCertificateListing(store: CertificateStore) { [store], ); + useEffect(() => { + const onFocus = () => { + if (fullListingsUnderWay.current > 0) return; + const mine = ++latestListing.current; + store.listOtherThanCards().then( + (certificates) => { + if (mine === latestListing.current) setListing({ kind: "listed", certificates }); + }, + () => {}, + ); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [store]); + const installed = installedCertificates(listing.kind === "listed" ? listing.certificates : []); const install = useCallback(async () => { diff --git a/rfirma-app/src/tauriSigning.test.ts b/rfirma-app/src/tauriSigning.test.ts index 1de95319..03d5220f 100644 --- a/rfirma-app/src/tauriSigning.test.ts +++ b/rfirma-app/src/tauriSigning.test.ts @@ -184,6 +184,14 @@ describe("los puertos de firma sobre Tauri", () => { expect(invoke).toHaveBeenCalledWith("list_certificates"); }); + it("lists the stores that are not cards with its own command", async () => { + invoke.mockResolvedValue([]); + + await tauriCertificateStore().listOtherThanCards(); + + expect(invoke).toHaveBeenCalledWith("list_certificates_other_than_cards"); + }); + it("empties the rFirma store with its own command", async () => { invoke.mockResolvedValue(undefined); diff --git a/rfirma-app/src/tauriSigning.ts b/rfirma-app/src/tauriSigning.ts index da248538..aad39391 100644 --- a/rfirma-app/src/tauriSigning.ts +++ b/rfirma-app/src/tauriSigning.ts @@ -36,6 +36,7 @@ const CARD_READERS = "card-readers"; export function tauriCertificateStore(): CertificateStore { return { list: () => invoke("list_certificates"), + listOtherThanCards: () => invoke("list_certificates_other_than_cards"), install: () => invoke("install_certificate"), remove: (id) => invoke("remove_certificate", { id }), emptyStore: () => invoke("empty_installed_store"),