Skip to main content

aen_window/
clipboard.rs

1//! Bounded UTF-8 clipboard transactions on the compositor's owner control socket.
2//! Scope zero is trusted session-operator access. Native window scopes are
3//! checked by KnottCompositor; these scopes are not a same-UID application sandbox.
4use std::io::{self, Read, Write};
5use std::net::Shutdown;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{mpsc, Arc};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant};
11
12/// Maximum UTF-8 selection retained by the compositor.
13pub const MAX_TEXT_BYTES: usize = 64 * 1024;
14/// Hex chunks fit the unchanged 1024-byte control request limit.
15pub const CHUNK_BYTES: usize = 384;
16const DEADLINE: Duration = Duration::from_secs(5);
17
18/// Parsed, bounded native text clipboard control operation.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Command {
21    /// Metadata only, never selection text.
22    Status,
23    /// Snapshot the current selection generation and byte length.
24    Read(u64),
25    /// Read one bounded chunk: window scope, generation, offset.
26    Chunk(u64, u64, usize),
27    /// Reserve one atomic write: window scope, total byte length.
28    Begin(u64, usize),
29    /// Append a chunk: window scope, transaction, offset, bytes.
30    Append(u64, u64, usize, Vec<u8>),
31    /// Publish a complete valid UTF-8 write: window scope, transaction.
32    Commit(u64, u64),
33    /// Cancel an unfinished write: window scope, transaction.
34    Cancel(u64, u64),
35    /// Clear the committed selection for a window scope or operator.
36    Clear(u64),
37}
38impl Command {
39    /// Compositor-issued window ID, or zero for the trusted operator control plane.
40    pub fn scope(&self) -> u64 {
41        match self {
42            Self::Status => 0,
43            Self::Read(s)
44            | Self::Clear(s)
45            | Self::Chunk(s, ..)
46            | Self::Begin(s, ..)
47            | Self::Append(s, ..)
48            | Self::Commit(s, ..)
49            | Self::Cancel(s, ..) => *s,
50        }
51    }
52}
53fn number(text: &str) -> Result<u64, &'static str> {
54    if text.is_empty()
55        || text.len() > 20
56        || !text.bytes().all(|b| b.is_ascii_digit())
57        || (text.len() > 1 && text.starts_with('0'))
58    {
59        return Err("invalid clipboard number");
60    }
61    text.parse().map_err(|_| "invalid clipboard number")
62}
63fn offset(text: &str) -> Result<usize, &'static str> {
64    let n = number(text)?;
65    if n > MAX_TEXT_BYTES as u64 {
66        return Err("clipboard size exceeds limit");
67    }
68    Ok(n as usize)
69}
70/// Parse strict bounded commands without retaining an arbitrary command body.
71pub fn parse(text: &str) -> Result<Command, &'static str> {
72    if text.len() > 1024 || text.bytes().any(|b| b.is_ascii_control()) {
73        return Err("invalid clipboard command");
74    }
75    let words: Vec<_> = text.split(' ').collect();
76    match words.as_slice() {
77        ["clipboard", "native", "status", "--json"] => Ok(Command::Status),
78        ["clipboard", "read", s] => Ok(Command::Read(number(s)?)),
79        ["clipboard", "chunk", s, g, n] => Ok(Command::Chunk(number(s)?, number(g)?, offset(n)?)),
80        ["clipboard", "begin", s, n] => Ok(Command::Begin(number(s)?, offset(n)?)),
81        ["clipboard", "append", s, t, n, bytes] => Ok(Command::Append(
82            number(s)?,
83            number(t)?,
84            offset(n)?,
85            unhex(bytes)?,
86        )),
87        ["clipboard", "commit", s, t] => Ok(Command::Commit(number(s)?, number(t)?)),
88        ["clipboard", "cancel", s, t] => Ok(Command::Cancel(number(s)?, number(t)?)),
89        ["clipboard", "clear", s] => Ok(Command::Clear(number(s)?)),
90        _ => Err("invalid clipboard command"),
91    }
92}
93fn hex(bytes: &[u8]) -> String {
94    const DIGITS: &[u8] = b"0123456789abcdef";
95    let mut result = String::with_capacity(bytes.len() * 2);
96    for b in bytes {
97        result.push(DIGITS[(b >> 4) as usize] as char);
98        result.push(DIGITS[(b & 15) as usize] as char);
99    }
100    result
101}
102fn unhex(text: &str) -> Result<Vec<u8>, &'static str> {
103    if text.is_empty() || text.len() > CHUNK_BYTES * 2 || text.len() & 1 != 0 {
104        return Err("invalid clipboard chunk");
105    }
106    let digit = |b| match b {
107        b'0'..=b'9' => Ok(b - b'0'),
108        b'a'..=b'f' => Ok(b - b'a' + 10),
109        _ => Err("invalid clipboard chunk"),
110    };
111    text.as_bytes()
112        .chunks_exact(2)
113        .map(|c| Ok(digit(c[0])? * 16 + digit(c[1])?))
114        .collect()
115}
116
117/// Synchronous native clipboard client; GUI clients should use `Worker`.
118pub struct Client {
119    socket: PathBuf,
120    scope: u64,
121    deadline: Instant,
122    stop: Arc<AtomicBool>,
123}
124impl Client {
125    /// Start one operation with an absolute deadline on a selected compositor.
126    pub fn new(socket: &Path, scope: u64) -> Self {
127        Self {
128            socket: socket.into(),
129            scope,
130            deadline: Instant::now() + DEADLINE,
131            stop: Arc::new(AtomicBool::new(false)),
132        }
133    }
134    fn check(&self) -> io::Result<Duration> {
135        if self.stop.load(Ordering::Relaxed) {
136            return Err(io::ErrorKind::Interrupted.into());
137        }
138        let remaining = self.deadline.saturating_duration_since(Instant::now());
139        if remaining.is_zero() {
140            return Err(io::ErrorKind::TimedOut.into());
141        }
142        Ok(remaining.min(Duration::from_millis(250)))
143    }
144    fn query(&self, text: &str) -> io::Result<String> {
145        self.check()?;
146        let mut socket = crate::platform::connect_local_until(&self.socket, self.deadline)?;
147        socket.set_nonblocking(false)?;
148        socket.set_write_timeout(Some(self.check()?))?;
149        socket.write_all(text.as_bytes())?;
150        socket.set_write_timeout(Some(self.check()?))?;
151        socket.write_all(b"\n")?;
152        socket.shutdown(Shutdown::Write)?;
153        let mut result = Vec::new();
154        let mut buffer = [0; 256];
155        loop {
156            socket.set_read_timeout(Some(self.check()?))?;
157            let n = socket.read(&mut buffer)?;
158            if n == 0 {
159                break;
160            }
161            result.extend_from_slice(&buffer[..n]);
162            if result.len() > 1024 {
163                return Err(io::Error::other("native clipboard response exceeds limit"));
164            }
165        }
166        let result = String::from_utf8(result)
167            .map_err(|_| io::Error::other("invalid native clipboard response"))?;
168        let result = result.strip_suffix('\n').unwrap_or(&result);
169        if !result.starts_with("ok") || result.contains(['\r', '\n', '\0']) {
170            return Err(io::Error::other(
171                "native clipboard request unavailable or denied",
172            ));
173        }
174        self.check()?;
175        Ok(result.into())
176    }
177    /// Retrieve one complete generation; changes during transfer reject the read.
178    pub fn read(&self) -> io::Result<String> {
179        let response = self.query(&format!("clipboard read {}", self.scope))?;
180        let (generation, len) = summary(&response)?;
181        let mut bytes = Vec::with_capacity(len);
182        while bytes.len() < len {
183            let result = self.query(&format!(
184                "clipboard chunk {} {} {}",
185                self.scope,
186                generation,
187                bytes.len()
188            ))?;
189            let chunk = result
190                .strip_prefix("ok chunk ")
191                .ok_or_else(|| io::Error::other("invalid clipboard chunk response"))?;
192            let chunk = unhex(chunk).map_err(io::Error::other)?;
193            if chunk.len() != CHUNK_BYTES.min(len - bytes.len()) {
194                return Err(io::Error::other("invalid clipboard chunk length"));
195            }
196            bytes.extend_from_slice(&chunk);
197        }
198        String::from_utf8(bytes).map_err(|_| io::Error::other("invalid clipboard UTF-8"))
199    }
200    /// Atomically replace UTF-8 text; incomplete writes preserve the old value.
201    pub fn write(&self, text: &str) -> io::Result<()> {
202        if text.len() > MAX_TEXT_BYTES {
203            return Err(io::Error::other("clipboard text exceeds limit"));
204        }
205        let response = self.query(&format!("clipboard begin {} {}", self.scope, text.len()))?;
206        let txn = response
207            .strip_prefix("ok write ")
208            .and_then(|s| number(s).ok())
209            .filter(|n| *n > 0)
210            .ok_or_else(|| io::Error::other("invalid clipboard transaction response"))?;
211        let result = (|| {
212            for (i, chunk) in text.as_bytes().chunks(CHUNK_BYTES).enumerate() {
213                if self.query(&format!(
214                    "clipboard append {} {} {} {}",
215                    self.scope,
216                    txn,
217                    i * CHUNK_BYTES,
218                    hex(chunk)
219                ))? != "ok"
220                {
221                    return Err(io::Error::other("invalid clipboard append response"));
222                }
223            }
224            let response = self.query(&format!("clipboard commit {} {}", self.scope, txn))?;
225            let (_, bytes) = summary(&response)?;
226            if bytes != text.len() {
227                return Err(io::Error::other("invalid clipboard commit response"));
228            }
229            Ok(())
230        })();
231        if result.is_err() {
232            let _ = self.query(&format!("clipboard cancel {} {}", self.scope, txn));
233        }
234        result
235    }
236    /// Explicitly clear the compositor's retained selection.
237    pub fn clear(&self) -> io::Result<()> {
238        summary(&self.query(&format!("clipboard clear {}", self.scope))?).map(|_| ())
239    }
240}
241fn summary(text: &str) -> io::Result<(u64, usize)> {
242    let words: Vec<_> = text.split(' ').collect();
243    match words.as_slice() {
244        ["ok", "clipboard", generation, len] => Ok((
245            number(generation).map_err(io::Error::other)?,
246            offset(len).map_err(io::Error::other)?,
247        )),
248        _ => Err(io::Error::other("invalid clipboard summary")),
249    }
250}
251/// Select the control endpoint corresponding to an explicitly selected window endpoint.
252pub fn socket_for_window(path: &Path) -> io::Result<PathBuf> {
253    use std::os::unix::ffi::OsStrExt;
254    let name = path
255        .file_name()
256        .ok_or_else(|| io::Error::other("missing native endpoint name"))?
257        .as_bytes();
258    let name = name
259        .strip_suffix(b".windows")
260        .filter(|n| !n.is_empty())
261        .ok_or_else(|| {
262            io::Error::other("native clipboard requires a matching compositor control endpoint")
263        })?;
264    Ok(path.with_file_name(std::ffi::OsStr::from_bytes(name)))
265}
266struct Job {
267    id: u64,
268    scope: u64,
269    text: Option<String>,
270}
271/// Completion for one asynchronous copy or paste request.
272pub struct Outcome {
273    /// Application request serial.
274    pub id: u64,
275    /// Copy returns None; paste returns complete UTF-8 text.
276    pub value: io::Result<Option<String>>,
277}
278/// One bounded request/result slot, serviced away from the application's UI loop.
279pub struct Worker {
280    sender: Option<mpsc::SyncSender<Job>>,
281    receiver: mpsc::Receiver<Outcome>,
282    stop: Arc<AtomicBool>,
283    thread: Option<JoinHandle<()>>,
284    next: u64,
285    busy: bool,
286}
287impl Worker {
288    /// Prepare a worker without connecting or requesting clipboard access.
289    pub fn new(socket: PathBuf) -> io::Result<Self> {
290        let (sender, jobs) = mpsc::sync_channel::<Job>(1);
291        let (results, receiver) = mpsc::sync_channel(1);
292        let stop = Arc::new(AtomicBool::new(false));
293        let cancel = Arc::clone(&stop);
294        let thread = thread::Builder::new()
295            .name("aen-clipboard".into())
296            .spawn(move || {
297                while let Ok(job) = jobs.recv() {
298                    if cancel.load(Ordering::Relaxed) {
299                        break;
300                    }
301                    let mut client = Client::new(&socket, job.scope);
302                    client.stop = Arc::clone(&cancel);
303                    let value = match job.text {
304                        Some(text) => client.write(&text).map(|_| None),
305                        None => client.read().map(Some),
306                    };
307                    if results.try_send(Outcome { id: job.id, value }).is_err() {
308                        break;
309                    }
310                }
311            })?;
312        Ok(Self {
313            sender: Some(sender),
314            receiver,
315            stop,
316            thread: Some(thread),
317            next: 0,
318            busy: false,
319        })
320    }
321    /// Queue copy (Some text) or paste (None) for a compositor-issued native window ID.
322    pub fn request(&mut self, scope: u64, text: Option<String>) -> io::Result<u64> {
323        if self.busy {
324            return Err(io::ErrorKind::WouldBlock.into());
325        }
326        if text.as_ref().is_some_and(|s| s.len() > MAX_TEXT_BYTES) {
327            return Err(io::Error::other("clipboard text exceeds limit"));
328        }
329        let id = self
330            .next
331            .checked_add(1)
332            .ok_or_else(|| io::Error::other("clipboard serial exhausted"))?;
333        self.sender
334            .as_ref()
335            .ok_or(io::ErrorKind::BrokenPipe)?
336            .try_send(Job { id, scope, text })
337            .map_err(|_| {
338                io::Error::new(io::ErrorKind::WouldBlock, "native clipboard worker is busy")
339            })?;
340        self.next = id;
341        self.busy = true;
342        Ok(id)
343    }
344    /// Nonblocking completion; callers must still validate current focus.
345    pub fn poll(&mut self) -> Option<Outcome> {
346        match self.receiver.try_recv() {
347            Ok(result) => {
348                self.busy = false;
349                Some(result)
350            }
351            _ => None,
352        }
353    }
354}
355impl Drop for Worker {
356    fn drop(&mut self) {
357        self.stop.store(true, Ordering::Relaxed);
358        self.sender.take();
359        if let Some(thread) = self.thread.take() {
360            let _ = thread.join();
361        }
362    }
363}
364
365#[cfg(feature = "server")]
366mod store {
367    use super::*;
368    struct Pending {
369        scope: u64,
370        id: u64,
371        total: usize,
372        bytes: Vec<u8>,
373        deadline: Instant,
374    }
375    /// Compositor-owned, bounded selection and one atomic pending replacement.
376    pub struct Store {
377        text: String,
378        generation: u64,
379        next: u64,
380        pending: Option<Pending>,
381    }
382    impl Default for Store {
383        fn default() -> Self {
384            Self {
385                text: String::new(),
386                generation: 1,
387                next: 0,
388                pending: None,
389            }
390        }
391    }
392    impl Store {
393        /// Cancel a window's unfinished write when its focus/ownership is lost.
394        pub fn cancel_scope(&mut self, scope: u64) {
395            if self.pending.as_ref().is_some_and(|p| p.scope == scope) {
396                self.pending = None;
397            }
398        }
399        /// Execute one validated command without logging selection data.
400        pub fn handle(&mut self, command: &Command) -> String {
401            self.handle_at(command, Instant::now())
402                .unwrap_or_else(|error| format!("error: {error}"))
403        }
404        fn handle_at(&mut self, command: &Command, now: Instant) -> Result<String, &'static str> {
405            if self.pending.as_ref().is_some_and(|p| now >= p.deadline) {
406                self.pending = None;
407            }
408            let summary =
409                |generation: u64, bytes: usize| format!("ok clipboard {generation} {bytes}");
410            match command {
411                Command::Status => Ok(format!(
412                    "{{\"transport\":\"aen\",\"mime_type\":\"text/plain;charset=utf-8\",\"generation\":{},\"bytes\":{},\"pending_bytes\":{},\"max_bytes\":{MAX_TEXT_BYTES}}}",
413                    self.generation, self.text.len(), self.pending.as_ref().map_or(0, |p| p.bytes.len())
414                )),
415                Command::Read(_) => Ok(summary(self.generation, self.text.len())),
416                Command::Chunk(_, generation, offset) => {
417                    if *generation != self.generation || *offset >= self.text.len() {
418                        return Err("clipboard generation or offset is stale");
419                    }
420                    let end = self.text.len().min(*offset + CHUNK_BYTES);
421                    Ok(format!("ok chunk {}", hex(&self.text.as_bytes()[*offset..end])))
422                }
423                Command::Begin(scope, total) => {
424                    if *total > MAX_TEXT_BYTES {
425                        return Err("clipboard size exceeds limit");
426                    }
427                    if self.pending.is_some() {
428                        return Err("clipboard writer is busy");
429                    }
430                    self.next = self.next.checked_add(1).ok_or("clipboard serial exhausted")?;
431                    self.pending = Some(Pending {
432                        scope: *scope, id: self.next, total: *total,
433                        bytes: Vec::with_capacity(*total), deadline: now + DEADLINE,
434                    });
435                    Ok(format!("ok write {}", self.next))
436                }
437                Command::Append(scope, id, offset, bytes) => {
438                    let p = self.pending.as_mut()
439                        .filter(|p| p.scope == *scope && p.id == *id)
440                        .ok_or("clipboard transaction is stale")?;
441                    if *offset != p.bytes.len() || bytes.is_empty()
442                        || bytes.len() > CHUNK_BYTES || bytes.len() > p.total.saturating_sub(p.bytes.len()) {
443                        return Err("clipboard chunk is invalid");
444                    }
445                    p.bytes.extend_from_slice(bytes);
446                    Ok("ok".into())
447                }
448                Command::Commit(scope, id) => {
449                    let p = self.pending.as_ref()
450                        .filter(|p| p.scope == *scope && p.id == *id)
451                        .ok_or("clipboard transaction is stale")?;
452                    if p.bytes.len() != p.total || std::str::from_utf8(&p.bytes).is_err() {
453                        return Err("clipboard write is incomplete or not UTF-8");
454                    }
455                    let generation = self.generation.checked_add(1).ok_or("clipboard generation exhausted")?;
456                    let p = self.pending.take().ok_or("clipboard transaction is stale")?;
457                    self.text = String::from_utf8(p.bytes).map_err(|_| "invalid clipboard UTF-8")?;
458                    self.generation = generation;
459                    Ok(summary(generation, self.text.len()))
460                }
461                Command::Cancel(scope, id) => {
462                    if !self.pending.as_ref().is_some_and(|p| p.scope == *scope && p.id == *id) {
463                        return Err("clipboard transaction is stale");
464                    }
465                    self.pending = None;
466                    Ok("ok".into())
467                }
468                Command::Clear(_) => {
469                    self.generation = self.generation.checked_add(1).ok_or("clipboard generation exhausted")?;
470                    self.text.clear();
471                    self.pending = None;
472                    Ok(summary(self.generation, 0))
473                }
474            }
475        }
476    }
477    #[cfg(test)]
478    mod tests {
479        use super::*;
480        #[test]
481        fn incomplete_invalid_stale_and_expired_writes_preserve_selection() {
482            let mut s = Store::default();
483            let now = Instant::now();
484            assert_eq!(
485                s.handle_at(&Command::Begin(7, 2), now).unwrap(),
486                "ok write 1"
487            );
488            assert!(s
489                .handle_at(&Command::Append(7, 1, 1, vec![1]), now)
490                .is_err());
491            assert!(s.handle_at(&Command::Commit(7, 1), now).is_err());
492            assert_eq!(
493                s.handle_at(&Command::Read(0), now).unwrap(),
494                "ok clipboard 1 0"
495            );
496            assert!(s.handle_at(&Command::Commit(7, 1), now + DEADLINE).is_err());
497            s.handle_at(&Command::Begin(7, 1), now).unwrap();
498            s.handle_at(&Command::Append(7, 2, 0, vec![0xff]), now)
499                .unwrap();
500            assert!(s.handle_at(&Command::Commit(7, 2), now).is_err());
501            s.cancel_scope(7);
502            assert!(s.pending.is_none());
503        }
504        #[test]
505        fn replacement_is_atomic_and_generations_prevent_mixed_reads() {
506            let mut s = Store::default();
507            let now = Instant::now();
508            s.handle_at(&Command::Begin(9, 3), now).unwrap();
509            s.handle_at(&Command::Append(9, 1, 0, b"old".to_vec()), now)
510                .unwrap();
511            assert_eq!(
512                s.handle_at(&Command::Commit(9, 1), now).unwrap(),
513                "ok clipboard 2 3"
514            );
515            s.handle_at(&Command::Begin(9, 3), now).unwrap();
516            s.handle_at(&Command::Append(9, 2, 0, b"new".to_vec()), now)
517                .unwrap();
518            assert_eq!(
519                s.handle_at(&Command::Chunk(0, 2, 0), now).unwrap(),
520                "ok chunk 6f6c64"
521            );
522            assert!(s.handle_at(&Command::Commit(8, 2), now).is_err());
523            s.handle_at(&Command::Commit(9, 2), now).unwrap();
524            assert!(s.handle_at(&Command::Chunk(0, 2, 0), now).is_err());
525            assert!(!s.handle(&Command::Status).contains("new"));
526            assert!(s
527                .handle_at(&Command::Begin(0, MAX_TEXT_BYTES + 1), now)
528                .is_err());
529        }
530    }
531}
532#[cfg(feature = "server")]
533pub use store::Store;
534#[cfg(test)]
535mod tests {
536    use super::*;
537    #[test]
538    fn strict_parser_and_chunks_cannot_expand_the_control_limit() {
539        for bad in [
540            "clipboard read -1",
541            "clipboard read 01",
542            "clipboard read 1 extra",
543            "clipboard  read 1",
544            "clipboard begin 0 65537",
545            "clipboard append 0 1 0 0",
546            "clipboard append 0 1 0 GG",
547            "clipboard read 1\nshutdown",
548        ] {
549            assert!(parse(bad).is_err(), "{bad}");
550        }
551        let text = format!(
552            "clipboard append {} {} {} {}",
553            u64::MAX,
554            u64::MAX,
555            MAX_TEXT_BYTES,
556            hex(&vec![255; CHUNK_BYTES])
557        );
558        assert!(text.len() < 1024);
559        assert!(parse(&text).is_ok());
560        assert_eq!(unhex(&hex("é🙂".as_bytes())).unwrap(), "é🙂".as_bytes());
561    }
562    #[test]
563    fn expired_operations_never_connect_and_cancellation_is_observed() {
564        let path = Path::new("/tmp/aen-clipboard-never-connected");
565        let mut client = Client::new(path, 0);
566        client.deadline = Instant::now();
567        assert_eq!(client.read().unwrap_err().kind(), io::ErrorKind::TimedOut);
568        assert_eq!(
569            crate::platform::connect_local_until(path, client.deadline)
570                .unwrap_err()
571                .kind(),
572            io::ErrorKind::TimedOut
573        );
574        client.deadline = Instant::now() + DEADLINE;
575        client.stop.store(true, Ordering::Relaxed);
576        assert_eq!(
577            client.read().unwrap_err().kind(),
578            io::ErrorKind::Interrupted
579        );
580    }
581    #[test]
582    fn selected_window_socket_determines_control_endpoint() {
583        assert_eq!(
584            socket_for_window(Path::new("/tmp/private.shell.windows")).unwrap(),
585            Path::new("/tmp/private.shell")
586        );
587        assert!(socket_for_window(Path::new("/tmp/other")).is_err());
588    }
589}