Implement clipboard and QR presentation
This commit is contained in:
@@ -15,6 +15,7 @@ gix.workspace = true
|
||||
gix-config.workspace = true
|
||||
keyring-core.workspace = true
|
||||
pgp.workspace = true
|
||||
qrcode.workspace = true
|
||||
rand.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
@@ -33,12 +34,17 @@ security-framework.workspace = true
|
||||
windows-native-keyring-store.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
arboard.workspace = true
|
||||
secret-service.workspace = true
|
||||
zbus-secret-service-keyring-store.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
arboard.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
rand_chacha = "0.3"
|
||||
rqrr.workspace = true
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
sha2 = "0.10"
|
||||
smallvec = "1.15"
|
||||
|
||||
@@ -6,11 +6,14 @@ use std::{
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
|
||||
|
||||
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
|
||||
@@ -23,6 +26,7 @@ pub struct Config {
|
||||
default_key: KeyIdentity,
|
||||
key_material: PathBuf,
|
||||
editor: Option<EditorCommand>,
|
||||
clipboard_timeout: ClipboardTimeout,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
}
|
||||
|
||||
@@ -52,6 +56,10 @@ impl Config {
|
||||
self.editor.as_ref()
|
||||
}
|
||||
|
||||
pub fn clipboard_timeout(&self) -> ClipboardTimeout {
|
||||
self.clipboard_timeout
|
||||
}
|
||||
|
||||
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||
&self.git_remotes
|
||||
}
|
||||
@@ -400,6 +408,7 @@ struct RawConfig {
|
||||
default_key: Option<String>,
|
||||
key_material: Option<PathBuf>,
|
||||
editor: Option<RawEditor>,
|
||||
clipboard_timeout_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
git: RawGit,
|
||||
}
|
||||
@@ -457,6 +466,13 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
|
||||
})
|
||||
.and_then(validate_key_identity)?;
|
||||
let editor = raw.editor.map(validate_editor).transpose()?;
|
||||
let clipboard_timeout = ClipboardTimeout::new(Duration::from_secs(
|
||||
raw.clipboard_timeout_seconds
|
||||
.unwrap_or(DEFAULT_CLIPBOARD_TIMEOUT.as_secs()),
|
||||
))
|
||||
.map_err(|_| ConfigError::InvalidField {
|
||||
field: "clipboard_timeout_seconds",
|
||||
})?;
|
||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||
|
||||
Ok(Config {
|
||||
@@ -465,6 +481,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
|
||||
default_key,
|
||||
key_material,
|
||||
editor,
|
||||
clipboard_timeout,
|
||||
git_remotes,
|
||||
})
|
||||
}
|
||||
@@ -601,7 +618,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
validate_table(
|
||||
root,
|
||||
"",
|
||||
&["vault", "default_key", "key_material", "editor", "git"],
|
||||
&[
|
||||
"vault",
|
||||
"default_key",
|
||||
"key_material",
|
||||
"editor",
|
||||
"clipboard_timeout_seconds",
|
||||
"git",
|
||||
],
|
||||
)?;
|
||||
let Some(git) = root.get("git") else {
|
||||
return Ok(());
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod crypto;
|
||||
pub mod generate;
|
||||
pub mod git;
|
||||
pub mod mutation;
|
||||
pub mod presentation;
|
||||
pub mod read;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
|
||||
301
crates/storage/src/presentation.rs
Normal file
301
crates/storage/src/presentation.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! 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<Self, ClipboardError> {
|
||||
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<u8>) -> 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<ClipboardContent, ClipboardError>;
|
||||
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<B> {
|
||||
backend: B,
|
||||
timeout: ClipboardTimeout,
|
||||
}
|
||||
|
||||
impl<B> fmt::Debug for ClipboardManager<B> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClipboardManager")
|
||||
.field("timeout", &self.timeout)
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: ClipboardBackend> ClipboardManager<B> {
|
||||
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<ClipboardDisposition, ClipboardError> {
|
||||
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<ClipboardDisposition, ClipboardError> {
|
||||
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<platform::NativeClipboardBackend>;
|
||||
|
||||
impl NativeClipboardManager {
|
||||
pub fn system(timeout: ClipboardTimeout) -> Result<Self, ClipboardError> {
|
||||
Ok(Self::new(platform::NativeClipboardBackend::new()?, timeout))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum QrError {
|
||||
EmptyPayload,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for QrError {}
|
||||
|
||||
/// A secret-derived, zeroizing QR symbol without presentation-platform choices.
|
||||
pub struct QrMatrix {
|
||||
width: usize,
|
||||
modules: Vec<u8>,
|
||||
}
|
||||
|
||||
impl QrMatrix {
|
||||
pub fn encode(payload: &SecretBytes) -> Result<Self, QrError> {
|
||||
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 })
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn is_dark(&self, x: usize, y: usize) -> Option<bool> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
85
crates/storage/src/presentation/platform.rs
Normal file
85
crates/storage/src/presentation/platform.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Safe native clipboard adapter selection.
|
||||
//!
|
||||
//! Desktop adapters are supplied by `arboard`, which owns the operating-system
|
||||
//! integration. IronStorage performs no direct FFI and contains no unsafe code.
|
||||
|
||||
use super::{ClipboardBackend, ClipboardContent, ClipboardError};
|
||||
use crate::repository::SecretBytes;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
pub struct NativeClipboardBackend {
|
||||
clipboard: arboard::Clipboard,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
impl NativeClipboardBackend {
|
||||
pub fn new() -> Result<Self, ClipboardError> {
|
||||
arboard::Clipboard::new()
|
||||
.map(|clipboard| Self { clipboard })
|
||||
.map_err(|_| ClipboardError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
impl ClipboardBackend for NativeClipboardBackend {
|
||||
fn read(&mut self) -> Result<ClipboardContent, ClipboardError> {
|
||||
match self.clipboard.get_text() {
|
||||
Ok(value) => Ok(ClipboardContent::text(value.into_bytes())),
|
||||
Err(arboard::Error::ContentNotAvailable) => Ok(ClipboardContent::EmptyOrNonText),
|
||||
Err(arboard::Error::ClipboardNotSupported) => Err(ClipboardError::Unavailable),
|
||||
Err(_) => Err(ClipboardError::ReadFailed),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError> {
|
||||
let text = std::str::from_utf8(value.expose()).map_err(|_| ClipboardError::NonUtf8)?;
|
||||
set_text(&mut self.clipboard, text).map_err(|error| match error {
|
||||
arboard::Error::ClipboardNotSupported => ClipboardError::Unavailable,
|
||||
_ => ClipboardError::WriteFailed,
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> Result<(), ClipboardError> {
|
||||
self.clipboard.clear().map_err(|error| match error {
|
||||
arboard::Error::ClipboardNotSupported => ClipboardError::Unavailable,
|
||||
_ => ClipboardError::WriteFailed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_text(clipboard: &mut arboard::Clipboard, text: &str) -> Result<(), arboard::Error> {
|
||||
use arboard::SetExtLinux as _;
|
||||
|
||||
clipboard.set().exclude_from_history().text(text)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
fn set_text(clipboard: &mut arboard::Clipboard, text: &str) -> Result<(), arboard::Error> {
|
||||
clipboard.set_text(text)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
pub struct NativeClipboardBackend;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
impl NativeClipboardBackend {
|
||||
pub fn new() -> Result<Self, ClipboardError> {
|
||||
Err(ClipboardError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
impl ClipboardBackend for NativeClipboardBackend {
|
||||
fn read(&mut self) -> Result<ClipboardContent, ClipboardError> {
|
||||
Err(ClipboardError::Unavailable)
|
||||
}
|
||||
|
||||
fn write(&mut self, _value: &SecretBytes) -> Result<(), ClipboardError> {
|
||||
Err(ClipboardError::Unavailable)
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> Result<(), ClipboardError> {
|
||||
Err(ClipboardError::Unavailable)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::{error::Error, ffi::OsStr, fs, path::Path};
|
||||
use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration};
|
||||
|
||||
use ironstorage::config::{ConfigError, ConfigLoader, EditorSource};
|
||||
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
|
||||
use tempfile::TempDir;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
@@ -78,6 +79,43 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||
);
|
||||
assert_eq!(remote.server_id().as_str(), "personal-git");
|
||||
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
|
||||
assert_eq!(
|
||||
config.clipboard_timeout().duration(),
|
||||
DEFAULT_CLIPBOARD_TIMEOUT
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_timeout_defaults_overrides_and_rejects_unsafe_values() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(&fixture.valid_contents().replace(
|
||||
"editor = [\"code\", \"--wait\"]",
|
||||
"editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = 30",
|
||||
))?;
|
||||
let config = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
assert_eq!(
|
||||
config.clipboard_timeout().duration(),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
|
||||
for timeout in [0, 301] {
|
||||
fixture.write_explicit(&fixture.valid_contents().replace(
|
||||
"editor = [\"code\", \"--wait\"]",
|
||||
&format!("editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = {timeout}"),
|
||||
))?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))
|
||||
.expect_err("unsafe clipboard timeout"),
|
||||
ConfigError::InvalidField {
|
||||
field: "clipboard_timeout_seconds"
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
231
crates/storage/tests/presentation.rs
Normal file
231
crates/storage/tests/presentation.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
#![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(())
|
||||
}
|
||||
Reference in New Issue
Block a user