#![forbid(unsafe_code)] use std::{ error::Error, sync::{Arc, Mutex}, time::Duration, }; use ironstorage::{ presentation::{ ClipboardBackend, ClipboardContent, ClipboardDisposition, ClipboardError, ClipboardManager, ClipboardTimeout, ClipboardWait, DEFAULT_CLIPBOARD_TIMEOUT, QrError, QrMatrix, }, repository::SecretBytes, }; type TestResult = Result<(), Box>; #[derive(Default)] struct MemoryState { text: Option>, fail_read: bool, fail_write: bool, fail_clear: bool, } #[derive(Clone, Default)] struct MemoryClipboard(Arc>); impl MemoryClipboard { fn with_text(value: &[u8]) -> Self { let backend = Self::default(); backend.set_text(value); backend } fn set_text(&self, value: &[u8]) { let mut state = self.0.lock().expect("test clipboard mutex"); state.text = Some(value.to_vec()); } fn text(&self) -> Option> { self.0.lock().expect("test clipboard mutex").text.clone() } fn fail_cleanup_read(&self) { self.0.lock().expect("test clipboard mutex").fail_read = true; } } impl ClipboardBackend for MemoryClipboard { fn read(&mut self) -> Result { let mut state = self.0.lock().map_err(|_| ClipboardError::ReadFailed)?; if std::mem::take(&mut state.fail_read) { return Err(ClipboardError::ReadFailed); } match &state.text { Some(value) => Ok(ClipboardContent::text(value.clone())), None => Ok(ClipboardContent::EmptyOrNonText), } } fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError> { let mut state = self.0.lock().map_err(|_| ClipboardError::WriteFailed)?; if std::mem::take(&mut state.fail_write) { return Err(ClipboardError::WriteFailed); } state.text = Some(value.expose().to_vec()); Ok(()) } fn clear(&mut self) -> Result<(), ClipboardError> { let mut state = self.0.lock().map_err(|_| ClipboardError::WriteFailed)?; if std::mem::take(&mut state.fail_clear) { return Err(ClipboardError::WriteFailed); } state.text = None; Ok(()) } } #[test] fn clipboard_timeout_restores_previous_text_and_clears_empty_content() -> TestResult { let backend = MemoryClipboard::with_text(b"previous clipboard"); let observer = backend.clone(); let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); let secret = SecretBytes::new(b"copied secret".to_vec()); let disposition = manager.copy_with(&secret, |duration| { assert_eq!(duration, DEFAULT_CLIPBOARD_TIMEOUT); assert_eq!( observer.text().as_deref(), Some(b"copied secret".as_slice()) ); ClipboardWait::Elapsed })?; assert_eq!(disposition, ClipboardDisposition::RestoredPrevious); assert_eq!( observer.text().as_deref(), Some(b"previous clipboard".as_slice()) ); let backend = MemoryClipboard::default(); let observer = backend.clone(); let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); assert_eq!( manager.copy_with(&secret, |_| ClipboardWait::Elapsed)?, ClipboardDisposition::Cleared ); assert!(observer.text().is_none()); assert!(!format!("{manager:?}").contains("copied secret")); Ok(()) } #[test] fn clipboard_races_and_cancellation_never_overwrite_newer_user_content() -> TestResult { let backend = MemoryClipboard::with_text(b"previous"); let observer = backend.clone(); let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); let secret = SecretBytes::new(b"secret".to_vec()); assert_eq!( manager.copy_with(&secret, |_| { observer.set_text(b"new user value"); ClipboardWait::Elapsed })?, ClipboardDisposition::PreservedNewer ); assert_eq!( observer.text().as_deref(), Some(b"new user value".as_slice()) ); observer.set_text(b"previous"); assert_eq!( manager.copy_with(&secret, |_| ClipboardWait::Cancelled), Err(ClipboardError::Cancelled) ); assert_eq!(observer.text().as_deref(), Some(b"previous".as_slice())); Ok(()) } #[test] fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult { assert_eq!( ClipboardTimeout::new(Duration::ZERO), Err(ClipboardError::InvalidTimeout) ); assert_eq!( ClipboardTimeout::new(Duration::from_secs(301)), Err(ClipboardError::InvalidTimeout) ); let backend = MemoryClipboard::default(); let observer = backend.clone(); let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); assert_eq!( manager.copy_with(&SecretBytes::new(Vec::new()), |_| ClipboardWait::Elapsed), Err(ClipboardError::EmptySecret) ); let secret = SecretBytes::new(b"redacted secret".to_vec()); let content = ClipboardContent::text(b"redacted previous clipboard".to_vec()); assert!(!format!("{content:?}").contains("redacted previous clipboard")); assert_eq!( manager.copy_with(&secret, |_| { observer.fail_cleanup_read(); ClipboardWait::Elapsed }), Err(ClipboardError::CleanupFailed) ); assert!( !ClipboardError::CleanupFailed .to_string() .contains("redacted secret") ); Ok(()) } #[test] fn otp_codes_and_uris_use_the_same_secret_safe_clipboard_lifecycle() -> TestResult { for payload in [ b"287082".as_slice(), b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example".as_slice(), ] { let backend = MemoryClipboard::default(); let observer = backend.clone(); let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); let secret = SecretBytes::new(payload.to_vec()); assert_eq!( manager.copy_with(&secret, |_| { assert_eq!(observer.text().as_deref(), Some(payload)); ClipboardWait::Elapsed })?, ClipboardDisposition::Cleared ); assert!(observer.text().is_none()); } Ok(()) } #[test] fn qr_matrices_round_trip_and_render_without_plaintext() -> TestResult { for payload in [ "correct horse battery staple", "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", "Unicode password: ๅ’–ๅ•กโ˜•", ] { let secret = SecretBytes::new(payload.as_bytes().to_vec()); let matrix = QrMatrix::encode(&secret)?; let simple = rqrr::SimpleGrid::from_func(matrix.width(), |x, y| { matrix.is_dark(x, y).expect("coordinates are in range") }); let (_, decoded) = rqrr::Grid::new(simple).decode()?; assert_eq!(decoded, payload); let terminal = matrix.render_terminal(); assert!(terminal.expose().ends_with(b"\n")); assert!( !terminal .expose() .windows(payload.len()) .any(|part| part == payload.as_bytes()) ); assert!(!format!("{matrix:?}").contains(payload)); } assert!(matches!( QrMatrix::encode(&SecretBytes::new(Vec::new())), Err(QrError::EmptyPayload) )); assert!(matches!( QrMatrix::encode(&SecretBytes::new(vec![b'x'; 4096])), Err(QrError::PayloadTooLarge) )); Ok(()) }