Files
IronStorage/crates/storage/tests/presentation.rs
2026-08-10 00:49:42 +00:00

232 lines
7.5 KiB
Rust

#![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<dyn Error>>;
#[derive(Default)]
struct MemoryState {
text: Option<Vec<u8>>,
fail_read: bool,
fail_write: bool,
fail_clear: bool,
}
#[derive(Clone, Default)]
struct MemoryClipboard(Arc<Mutex<MemoryState>>);
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<Vec<u8>> {
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<ClipboardContent, ClipboardError> {
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(())
}