//! Secret-safe clipboard lifecycle and platform-neutral QR presentation. use std::{error::Error, fmt, time::Duration}; use qrcode::{EcLevel, QrCode, types::Color}; use zeroize::Zeroize as _; use crate::repository::SecretBytes; mod platform; pub const DEFAULT_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(45); pub const MAX_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(5 * 60); const QR_QUIET_ZONE: usize = 4; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ClipboardTimeout(Duration); impl ClipboardTimeout { pub fn new(duration: Duration) -> Result { if duration.is_zero() || duration > MAX_CLIPBOARD_TIMEOUT { return Err(ClipboardError::InvalidTimeout); } Ok(Self(duration)) } pub const fn pass_default() -> Self { Self(DEFAULT_CLIPBOARD_TIMEOUT) } pub const fn duration(self) -> Duration { self.0 } } impl Default for ClipboardTimeout { fn default() -> Self { Self::pass_default() } } pub enum ClipboardContent { Text(SecretBytes), EmptyOrNonText, } impl ClipboardContent { pub fn text(value: Vec) -> Self { Self::Text(SecretBytes::new(value)) } } impl fmt::Debug for ClipboardContent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Text(_) => formatter.write_str("ClipboardContent::Text([REDACTED])"), Self::EmptyOrNonText => formatter.write_str("ClipboardContent::EmptyOrNonText"), } } } /// Byte-oriented adapter contract for native clipboards and deterministic tests. pub trait ClipboardBackend { fn read(&mut self) -> Result; fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError>; fn clear(&mut self) -> Result<(), ClipboardError>; } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardWait { Elapsed, Cancelled, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardDisposition { RestoredPrevious, Cleared, PreservedNewer, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardError { InvalidTimeout, EmptySecret, NonUtf8, Unavailable, ReadFailed, WriteFailed, CleanupFailed, Cancelled, } impl fmt::Display for ClipboardError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let message = match self { Self::InvalidTimeout => "the clipboard timeout is invalid", Self::EmptySecret => "empty data cannot be presented on the clipboard", Self::NonUtf8 => "the native clipboard accepts only UTF-8 text", Self::Unavailable => "the native clipboard is unavailable", Self::ReadFailed => "the native clipboard could not be read", Self::WriteFailed => "the native clipboard could not be written", Self::CleanupFailed => "the clipboard secret could not be cleaned up safely", Self::Cancelled => "clipboard presentation was cancelled", }; formatter.write_str(message) } } impl Error for ClipboardError {} pub struct ClipboardManager { backend: B, timeout: ClipboardTimeout, } impl fmt::Debug for ClipboardManager { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ClipboardManager") .field("timeout", &self.timeout) .field("contents", &"[REDACTED]") .finish() } } impl ClipboardManager { pub fn new(backend: B, timeout: ClipboardTimeout) -> Self { Self { backend, timeout } } pub fn timeout(&self) -> ClipboardTimeout { self.timeout } /// Copy a secret, wait under caller-controlled scheduling, and then clean up. /// /// Cleanup restores the previous UTF-8 value (or clears a previous non-text /// value) only while the copied secret is still current. A newer clipboard /// value is never overwritten. pub fn copy_with( &mut self, value: &SecretBytes, wait: impl FnOnce(Duration) -> ClipboardWait, ) -> Result { if value.expose().is_empty() { return Err(ClipboardError::EmptySecret); } let previous = self.backend.read()?; self.backend.write(value)?; let wait_result = wait(self.timeout.duration()); let disposition = self.cleanup(value, &previous)?; if wait_result == ClipboardWait::Cancelled { return Err(ClipboardError::Cancelled); } Ok(disposition) } fn cleanup( &mut self, copied: &SecretBytes, previous: &ClipboardContent, ) -> Result { let current = self .backend .read() .map_err(|_| ClipboardError::CleanupFailed)?; let ClipboardContent::Text(current) = current else { return Ok(ClipboardDisposition::PreservedNewer); }; if current.expose() != copied.expose() { return Ok(ClipboardDisposition::PreservedNewer); } match previous { ClipboardContent::Text(previous) => self .backend .write(previous) .map(|()| ClipboardDisposition::RestoredPrevious) .map_err(|_| ClipboardError::CleanupFailed), ClipboardContent::EmptyOrNonText => self .backend .clear() .map(|()| ClipboardDisposition::Cleared) .map_err(|_| ClipboardError::CleanupFailed), } } } pub type NativeClipboardManager = ClipboardManager; impl NativeClipboardManager { pub fn system(timeout: ClipboardTimeout) -> Result { Ok(Self::new(platform::NativeClipboardBackend::new()?, timeout)) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QrError { EmptyPayload, PayloadTooLarge, InvalidImage, NotFound, InvalidPayload, } impl fmt::Display for QrError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmptyPayload => formatter.write_str("empty data cannot be encoded as a QR code"), Self::PayloadTooLarge => formatter.write_str("the QR payload is too large"), Self::InvalidImage => formatter.write_str("the selected file is not a supported image"), Self::NotFound => formatter.write_str("the image does not contain a QR code"), Self::InvalidPayload => formatter.write_str("the QR code contains invalid text"), } } } impl Error for QrError {} /// A secret-derived, zeroizing QR symbol without presentation-platform choices. pub struct QrMatrix { width: usize, modules: Vec, } impl QrMatrix { pub fn encode(payload: &SecretBytes) -> Result { if payload.expose().is_empty() { return Err(QrError::EmptyPayload); } let code = QrCode::with_error_correction_level(payload.expose(), EcLevel::L) .map_err(|_| QrError::PayloadTooLarge)?; let width = code.width(); let modules = code .into_colors() .into_iter() .map(|color| u8::from(color == Color::Dark)) .collect(); Ok(Self { width, modules }) } /// Decode the first QR symbol from an encoded image without exposing the /// secret-derived payload to a presentation adapter. pub fn decode_image(image: &SecretBytes) -> Result { let grayscale = image::load_from_memory(image.expose()) .map_err(|_| QrError::InvalidImage)? .into_luma8(); let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( grayscale.width() as usize, grayscale.height() as usize, |x, y| grayscale.get_pixel(x as u32, y as u32).0[0], ); let grid = prepared .detect_grids() .into_iter() .next() .ok_or(QrError::NotFound)?; let (_, payload) = grid.decode().map_err(|_| QrError::InvalidPayload)?; Ok(SecretBytes::new(payload.into_bytes())) } pub fn width(&self) -> usize { self.width } pub fn is_dark(&self, x: usize, y: usize) -> Option { if x >= self.width || y >= self.width { return None; } Some(self.modules[y * self.width + x] != 0) } /// Render a QR symbol with the standard four-module quiet zone and square /// terminal cells. The returned bytes are secret-derived and zeroize on drop. pub fn render_terminal(&self) -> SecretBytes { let padded_width = self.width + 2 * QR_QUIET_ZONE; let padded_height = padded_width.next_multiple_of(2); let mut rendered = String::with_capacity(padded_width * padded_height * 2); for y in (0..padded_height).step_by(2) { for x in 0..padded_width { let top = self.padded_module(x, y); let bottom = self.padded_module(x, y + 1); let cell = match (top, bottom) { (true, true) => '█', (true, false) => '▀', (false, true) => '▄', (false, false) => ' ', }; rendered.push(cell); rendered.push(cell); } rendered.push('\n'); } SecretBytes::new(rendered.into_bytes()) } fn padded_module(&self, x: usize, y: usize) -> bool { let Some(x) = x.checked_sub(QR_QUIET_ZONE) else { return false; }; let Some(y) = y.checked_sub(QR_QUIET_ZONE) else { return false; }; self.is_dark(x, y).unwrap_or(false) } } impl Clone for QrMatrix { fn clone(&self) -> Self { Self { width: self.width, modules: self.modules.clone(), } } } impl fmt::Debug for QrMatrix { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("QrMatrix([REDACTED])") } } impl Drop for QrMatrix { fn drop(&mut self) { self.modules.zeroize(); } }