269 lines
8.8 KiB
Rust
269 lines
8.8 KiB
Rust
#![forbid(unsafe_code)]
|
|
|
|
use std::{
|
|
error::Error,
|
|
io::Cursor,
|
|
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(())
|
|
}
|
|
|
|
#[test]
|
|
fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResult {
|
|
let payload = SecretBytes::new(
|
|
b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example".to_vec(),
|
|
);
|
|
let matrix = QrMatrix::encode(&payload)?;
|
|
let scale = 8_u32;
|
|
let quiet = 4_u32;
|
|
let size = (matrix.width() as u32 + quiet * 2) * scale;
|
|
let mut image = image::GrayImage::from_pixel(size, size, image::Luma([255]));
|
|
for y in 0..matrix.width() {
|
|
for x in 0..matrix.width() {
|
|
if matrix.is_dark(x, y) == Some(true) {
|
|
for dy in 0..scale {
|
|
for dx in 0..scale {
|
|
image.put_pixel(
|
|
(x as u32 + quiet) * scale + dx,
|
|
(y as u32 + quiet) * scale + dy,
|
|
image::Luma([0]),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut encoded = Cursor::new(Vec::new());
|
|
image.write_to(&mut encoded, image::ImageFormat::Png)?;
|
|
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
|
|
assert_eq!(decoded.expose(), payload.expose());
|
|
assert!(matches!(
|
|
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
|
|
Err(QrError::InvalidImage)
|
|
));
|
|
Ok(())
|
|
}
|