Skip to main content

a7n_core/
clipboard.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Clipboard ordering, exclusion and validation without platform I/O.
4
5use a7n_proto::v1::{ClipboardAnnounce, ClipboardItem};
6use a7n_proto::{MachineId, limits};
7use sha2::{Digest, Sha256};
8
9pub const TEXT_PLAIN: &str = "text/plain;charset=utf-8";
10pub const IMAGE_PNG: &str = "image/png";
11pub const TEXT_HTML: &str = "text/html;charset=utf-8";
12/// Item that carries a `FileManifest` instead of bytes; the data is the files
13/// themselves, fetched on paste.
14pub const FILES: &str = "application/x-apeirogon-files";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub struct Generation {
18    pub generation: u64,
19    pub origin: MachineId,
20}
21
22#[derive(Debug, Default)]
23pub struct Clock {
24    seen: u64,
25}
26
27impl Clock {
28    pub fn observe(&mut self, generation: &Generation) {
29        self.seen = self.seen.max(generation.generation);
30    }
31
32    pub fn next(&mut self, own: MachineId) -> Generation {
33        // Peers cannot push `seen` past `limits::COUNTER_MAX`, so this never saturates.
34        self.seen = self.seen.saturating_add(1);
35        Generation {
36            generation: self.seen,
37            origin: own,
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Item {
44    pub mime: String,
45    pub data: Vec<u8>,
46}
47
48#[derive(Debug)]
49pub struct LocalChange {
50    pub items: Vec<Item>,
51    /// Local paths of copied files and directories; empty when the copy is not a file list.
52    pub files: Vec<std::path::PathBuf>,
53    pub sensitive: bool,
54    pub source_app: Option<String>,
55}
56
57#[derive(Debug)]
58pub struct Exclusion;
59
60impl Exclusion {
61    #[must_use]
62    pub fn allows(change: &LocalChange, deny_apps: &[String]) -> bool {
63        !change.sensitive
64            && !change.source_app.as_ref().is_some_and(|app| {
65                deny_apps
66                    .iter()
67                    .any(|denied| denied.eq_ignore_ascii_case(app))
68            })
69    }
70}
71
72fn supported(mime: &str) -> bool {
73    matches!(mime, TEXT_PLAIN | TEXT_HTML | IMAGE_PNG | FILES)
74}
75
76/// Maximum encoded bytes for a clipboard format. For [`FILES`] this bounds the
77/// `text/uri-list` handed to the OS, not the files, which are limited by policy only.
78#[must_use]
79pub fn limit_for(mime: &str) -> usize {
80    if mime == IMAGE_PNG {
81        limits::IMAGE_CLIPBOARD_MAX
82    } else {
83        limits::CLIPBOARD_TEXT_MAX
84    }
85}
86
87#[derive(Debug, thiserror::Error, PartialEq, Eq)]
88pub enum ImageError {
89    #[error("invalid PNG header")]
90    Header,
91    #[error("PNG dimensions exceed clipboard limits")]
92    Dimensions,
93}
94
95/// Check the PNG header before a platform decoder allocates pixel storage.
96/// # Errors
97/// Rejects truncated headers, invalid signatures/IHDR and unsafe dimensions.
98pub fn validate_png(bytes: &[u8]) -> Result<(u32, u32), ImageError> {
99    const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
100    const IHDR_LENGTH: [u8; 4] = 13_u32.to_be_bytes();
101    if bytes.len() < 33
102        || &bytes[..8] != SIGNATURE
103        || bytes[8..12] != IHDR_LENGTH
104        || &bytes[12..16] != b"IHDR"
105    {
106        return Err(ImageError::Header);
107    }
108    let width = u32::from_be_bytes(bytes[16..20].try_into().map_err(|_| ImageError::Header)?);
109    let height = u32::from_be_bytes(bytes[20..24].try_into().map_err(|_| ImageError::Header)?);
110    if width == 0
111        || height == 0
112        || width > limits::IMAGE_MAX_DIMENSION
113        || height > limits::IMAGE_MAX_DIMENSION
114        || u64::from(width) * u64::from(height) * 4 > limits::IMAGE_DECODED_MAX as u64
115    {
116        return Err(ImageError::Dimensions);
117    }
118    Ok((width, height))
119}
120
121#[must_use]
122pub fn announce(items: Vec<Item>, generation: Generation) -> Option<ClipboardAnnounce> {
123    let mut inline = 0;
124    let mut wire = Vec::new();
125    for item in items
126        .into_iter()
127        .filter(|item| item.mime != FILES && supported(&item.mime) && !item.data.is_empty())
128    {
129        if item.data.len() > limit_for(&item.mime) {
130            return None;
131        }
132        let size = item.data.len();
133        let sha256 = Sha256::digest(&item.data).to_vec();
134        let inline_data = if inline + size <= limits::CLIPBOARD_INLINE_MAX {
135            inline += size;
136            item.data
137        } else {
138            Vec::new()
139        };
140        wire.push(ClipboardItem {
141            mime: item.mime,
142            size: size as u64,
143            sha256,
144            inline_data,
145        });
146    }
147    (!wire.is_empty()).then_some(ClipboardAnnounce {
148        send: false,
149        origin: generation.origin.as_bytes().to_vec(),
150        generation: generation.generation,
151        items: wire,
152        files: None,
153    })
154}
155
156#[derive(Debug, thiserror::Error, PartialEq, Eq)]
157pub enum ClipboardError {
158    #[error("clipboard origin must be 16 bytes")]
159    Origin,
160    #[error("clipboard item exceeds its format limit")]
161    Size,
162    #[error("clipboard inline total exceeds the limit")]
163    InlineLimit,
164    #[error("clipboard inline length does not match size")]
165    Length,
166    #[error("clipboard digest is invalid")]
167    Digest,
168    #[error("clipboard generation exceeds the protocol limit")]
169    Generation,
170}
171
172#[derive(Debug)]
173pub struct Received {
174    pub generation: Generation,
175    pub items: Vec<Item>,
176    pub pending: Vec<u32>,
177}
178
179/// Validate before applying any data or advancing the current clipboard.
180/// # Errors
181/// Rejects invalid origins, lengths, digests and clipboard limits.
182pub fn accept(
183    current: Option<Generation>,
184    incoming: &ClipboardAnnounce,
185) -> Result<Option<Received>, ClipboardError> {
186    if incoming.generation > limits::COUNTER_MAX {
187        return Err(ClipboardError::Generation);
188    }
189    let generation = Generation {
190        generation: incoming.generation,
191        origin: MachineId::try_from(incoming.origin.as_slice())
192            .map_err(|_| ClipboardError::Origin)?,
193    };
194    let mut inline = 0;
195    let mut items = Vec::new();
196    let mut pending = Vec::new();
197    for (index, item) in incoming.items.iter().enumerate() {
198        if item.mime == FILES {
199            // Files carry no digest; each file in the manifest has its own.
200            if !item.inline_data.is_empty() || !item.sha256.is_empty() {
201                return Err(ClipboardError::Digest);
202            }
203            pending.push(u32::try_from(index).map_err(|_| ClipboardError::Size)?);
204            continue;
205        }
206        if item.size > limit_for(&item.mime) as u64 {
207            return Err(ClipboardError::Size);
208        }
209        if item.sha256.len() != Sha256::output_size() {
210            return Err(ClipboardError::Digest);
211        }
212        inline += item.inline_data.len();
213        if inline > limits::CLIPBOARD_INLINE_MAX {
214            return Err(ClipboardError::InlineLimit);
215        }
216        if !item.inline_data.is_empty() || item.size == 0 {
217            if item.inline_data.len() as u64 != item.size {
218                return Err(ClipboardError::Length);
219            }
220            if Sha256::digest(&item.inline_data).as_slice() != item.sha256 {
221                return Err(ClipboardError::Digest);
222            }
223            if supported(&item.mime) && item.size != 0 {
224                items.push(Item {
225                    mime: item.mime.clone(),
226                    data: item.inline_data.clone(),
227                });
228            }
229        } else if supported(&item.mime) {
230            pending.push(u32::try_from(index).map_err(|_| ClipboardError::Size)?);
231        }
232    }
233    if current.is_some_and(|current| generation <= current) {
234        return Ok(None);
235    }
236    Ok(Some(Received {
237        generation,
238        items,
239        pending,
240    }))
241}
242
243#[cfg(test)]
244mod tests;