Implement clipboard and QR presentation

This commit is contained in:
Hermes Agent
2026-08-10 00:49:42 +00:00
parent b685a4864c
commit b8c614e141
17 changed files with 1368 additions and 18 deletions

View File

@@ -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(());

View File

@@ -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;

View 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();
}
}

View 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)
}
}