1use 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";
12pub 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 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 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#[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
95pub 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
179pub 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 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;