Skip to main content

slipcase_open/
ipc.rs

1//! The front door: one instance, and every other invocation a client of it.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 8. Launching a document returns at once, so something has to outlive
7//! the launch to hold the watch, and a session list in the plural means a second
8//! invocation hands its container to the first rather than starting a rival with
9//! a list of its own.
10//!
11//! **The front door is a control surface and is treated as one.** §10 says a
12//! value supplied over IPC is a policy bypass, and the requests themselves are
13//! the same problem: a local process that can reach the endpoint could hand the
14//! engine a container, close a session before its final repack, or discard a
15//! recovery item. The endpoint sits in a directory only its owner can traverse,
16//! which is the platform's own mechanism and a requirement rather than a
17//! hardening measure. A frame longer than [`MAX_FRAME`] is refused before it is
18//! allocated, because a length somebody else chose is not a length to trust.
19//!
20//! ## The wire
21//!
22//! A frame is four bytes of big-endian length and then that many bytes. The
23//! body is NUL-separated fields, the first of which names the verb. NUL rather
24//! than a newline or a tab, because a path may contain either of those on Unix
25//! and may not contain NUL — a protocol a filename can break is a protocol that
26//! breaks on exactly the containers somebody was careless naming.
27
28use std::fmt;
29use std::io::{self, Read, Write};
30use std::path::PathBuf;
31
32/// The largest frame this will read. Requests are a verb and a path; anything
33/// approaching this is a mistake or an attempt.
34pub const MAX_FRAME: usize = 64 * 1024;
35
36/// Who says what came of an `open`.
37///
38/// An invocation started from a desktop entry has no terminal, so the lines it
39/// is handed back go nowhere and the person who double-clicked learns nothing —
40/// including, on the paths that matter most, that the content file was refused
41/// or that it is an executable wearing a document's name (concept 5.1). An
42/// invocation from a shell has a terminal and will print them itself, and an
43/// instance that also announced them would say everything twice.
44///
45/// Only the client knows which it is, so the client says. This decides where a
46/// message is shown and nothing else: concept 8 calls the front door a control
47/// surface, and a field that moves text between two of this tool's own outputs
48/// is not one of its controls.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Voice {
51    /// The client has somewhere to print, and will.
52    Client,
53    /// The client has not, so the instance speaks through concept 9's channel.
54    Instance,
55}
56
57/// What an invocation asks the resident instance to do.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Request {
60    /// Open this container, or bring its session forward if it is already open.
61    Open {
62        container: PathBuf,
63        /// Who says what came of it.
64        voice: Voice,
65    },
66    /// What is open, and what is left over.
67    List,
68    /// Close this session, by the name `List` gives it.
69    Close(String),
70    /// Are you there.
71    Ping,
72}
73
74/// What it answers.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Response {
77    /// Done, with lines for the caller to print.
78    Ok(Vec<String>),
79    /// Not done, and why.
80    Err(String),
81}
82
83/// A frame that could not be understood.
84#[derive(Debug)]
85pub enum Error {
86    /// The connection failed, or ended mid-frame.
87    Io(io::Error),
88    /// The frame said it was longer than [`MAX_FRAME`].
89    TooLong(usize),
90    /// The body was not a request or a response this build knows.
91    Malformed(String),
92}
93
94impl fmt::Display for Error {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::Io(e) => write!(f, "{e}"),
98            Self::TooLong(n) => write!(f, "a frame of {n} bytes is longer than {MAX_FRAME}"),
99            Self::Malformed(what) => write!(f, "unintelligible frame: {what}"),
100        }
101    }
102}
103
104impl std::error::Error for Error {}
105
106impl From<io::Error> for Error {
107    fn from(e: io::Error) -> Self {
108        Self::Io(e)
109    }
110}
111
112impl Request {
113    fn fields(&self) -> Vec<Vec<u8>> {
114        match self {
115            Self::Open { container, voice } => vec![
116                b"open".to_vec(),
117                path_bytes(container),
118                match voice {
119                    Voice::Client => b"client".to_vec(),
120                    Voice::Instance => b"instance".to_vec(),
121                },
122            ],
123            Self::List => vec![b"list".to_vec()],
124            Self::Close(id) => vec![b"close".to_vec(), id.as_bytes().to_vec()],
125            Self::Ping => vec![b"ping".to_vec()],
126        }
127    }
128
129    fn from_fields(fields: &[Vec<u8>]) -> Result<Self, Error> {
130        let verb = fields.first().map(Vec::as_slice).unwrap_or_default();
131        match (verb, fields.len()) {
132            (b"open", 3) => Ok(Self::Open {
133                container: path_from(&fields[1]),
134                voice: match fields[2].as_slice() {
135                    b"client" => Voice::Client,
136                    b"instance" => Voice::Instance,
137                    other => {
138                        return Err(Error::Malformed(format!(
139                            "open with an unknown voice {}",
140                            String::from_utf8_lossy(other)
141                        )))
142                    }
143                },
144            }),
145            (b"list", 1) => Ok(Self::List),
146            (b"close", 2) => Ok(Self::Close(text(&fields[1]))),
147            (b"ping", 1) => Ok(Self::Ping),
148            _ => Err(Error::Malformed(format!(
149                "{} with {} field(s)",
150                String::from_utf8_lossy(verb),
151                fields.len()
152            ))),
153        }
154    }
155}
156
157impl Response {
158    fn fields(&self) -> Vec<Vec<u8>> {
159        match self {
160            Self::Ok(lines) => std::iter::once(b"ok".to_vec())
161                .chain(lines.iter().map(|l| l.as_bytes().to_vec()))
162                .collect(),
163            Self::Err(why) => vec![b"err".to_vec(), why.as_bytes().to_vec()],
164        }
165    }
166
167    fn from_fields(fields: &[Vec<u8>]) -> Result<Self, Error> {
168        match fields.first().map(Vec::as_slice) {
169            Some(b"ok") => Ok(Self::Ok(fields[1..].iter().map(|f| text(f)).collect())),
170            Some(b"err") if fields.len() == 2 => Ok(Self::Err(text(&fields[1]))),
171            other => Err(Error::Malformed(format!(
172                "{}",
173                String::from_utf8_lossy(other.unwrap_or_default())
174            ))),
175        }
176    }
177}
178
179/// A path as bytes, without going through UTF-8, because a Unix path need not
180/// be UTF-8 and this must not refuse a container for how somebody named it.
181#[cfg(unix)]
182fn path_bytes(path: &std::path::Path) -> Vec<u8> {
183    use std::os::unix::ffi::OsStrExt as _;
184    path.as_os_str().as_bytes().to_vec()
185}
186
187#[cfg(unix)]
188fn path_from(bytes: &[u8]) -> PathBuf {
189    use std::os::unix::ffi::OsStringExt as _;
190    PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))
191}
192
193#[cfg(not(unix))]
194fn path_bytes(path: &std::path::Path) -> Vec<u8> {
195    path.to_string_lossy().into_owned().into_bytes()
196}
197
198#[cfg(not(unix))]
199fn path_from(bytes: &[u8]) -> PathBuf {
200    PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
201}
202
203fn text(bytes: &[u8]) -> String {
204    String::from_utf8_lossy(bytes).into_owned()
205}
206
207fn write_frame(to: &mut impl Write, fields: &[Vec<u8>]) -> Result<(), Error> {
208    let body = fields.join(&0u8);
209    let len = u32::try_from(body.len()).map_err(|_| Error::TooLong(body.len()))?;
210    if body.len() > MAX_FRAME {
211        return Err(Error::TooLong(body.len()));
212    }
213    to.write_all(&len.to_be_bytes())?;
214    to.write_all(&body)?;
215    to.flush()?;
216    Ok(())
217}
218
219fn read_frame(from: &mut impl Read) -> Result<Vec<Vec<u8>>, Error> {
220    let mut len = [0u8; 4];
221    from.read_exact(&mut len)?;
222    let len = u32::from_be_bytes(len) as usize;
223    // Checked before the allocation, not after. The length is a number the
224    // other end chose.
225    if len > MAX_FRAME {
226        return Err(Error::TooLong(len));
227    }
228    let mut body = vec![0u8; len];
229    from.read_exact(&mut body)?;
230    Ok(body.split(|b| *b == 0).map(<[u8]>::to_vec).collect())
231}
232
233/// Send a request and wait for the answer.
234///
235/// # Errors
236///
237/// Where the connection fails or the answer cannot be understood.
238pub fn ask(stream: &mut (impl Read + Write), request: &Request) -> Result<Response, Error> {
239    write_frame(stream, &request.fields())?;
240    Response::from_fields(&read_frame(stream)?)
241}
242
243/// Read one request.
244///
245/// # Errors
246///
247/// Where the connection fails or the request cannot be understood.
248pub fn take(stream: &mut impl Read) -> Result<Request, Error> {
249    Request::from_fields(&read_frame(stream)?)
250}
251
252/// Answer one request.
253///
254/// # Errors
255///
256/// Where the connection fails.
257pub fn answer(stream: &mut impl Write, response: &Response) -> Result<(), Error> {
258    write_frame(stream, &response.fields())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::{answer, ask, take, Error, Request, Response, Voice};
264    use std::io::Cursor;
265    use std::path::PathBuf;
266
267    /// A pair of ends that hand bytes to each other, so a round trip needs no
268    /// socket.
269    struct Pair {
270        to_them: Vec<u8>,
271        from_them: Cursor<Vec<u8>>,
272    }
273
274    impl std::io::Write for Pair {
275        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
276            self.to_them.write(buf)
277        }
278        fn flush(&mut self) -> std::io::Result<()> {
279            Ok(())
280        }
281    }
282
283    impl std::io::Read for Pair {
284        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
285            std::io::Read::read(&mut self.from_them, buf)
286        }
287    }
288
289    fn round_trip(request: &Request) -> Request {
290        let mut wire = Vec::new();
291        let mut out = Pair {
292            to_them: Vec::new(),
293            from_them: Cursor::new(Vec::new()),
294        };
295        super::write_frame(&mut out, &request.fields()).unwrap();
296        wire.extend_from_slice(&out.to_them);
297        take(&mut Cursor::new(wire)).unwrap()
298    }
299
300    #[test]
301    fn every_request_survives_the_wire() {
302        for r in [
303            Request::Open {
304                container: PathBuf::from("/tmp/report.slpc"),
305                voice: Voice::Client,
306            },
307            Request::List,
308            Request::Close("6a94-0".to_string()),
309            Request::Ping,
310        ] {
311            assert_eq!(round_trip(&r), r, "{r:?}");
312        }
313    }
314
315    #[cfg(unix)]
316    #[test]
317    fn a_path_a_line_protocol_would_break_survives() {
318        // Both are legal in a Unix filename and both would end a frame in a
319        // protocol delimited by them. NUL is the one byte a path cannot carry,
320        // which is why it is the separator.
321        for name in [
322            "/tmp/two\tcolumns.slpc",
323            "/tmp/two\nlines.slpc",
324            "/tmp/a \"quoted\" name.slpc",
325        ] {
326            let r = Request::Open {
327                container: PathBuf::from(name),
328                voice: Voice::Instance,
329            };
330            assert_eq!(round_trip(&r), r, "{name}");
331        }
332    }
333
334    #[cfg(unix)]
335    #[test]
336    fn a_path_that_is_not_utf8_survives() {
337        // A Unix path is bytes. Refusing one for how somebody named it would be
338        // this tool deciding which containers may be opened on grounds SPEC has
339        // no opinion about.
340        use std::os::unix::ffi::OsStringExt as _;
341        let name = PathBuf::from(std::ffi::OsString::from_vec(vec![
342            b'/', b't', b'm', b'p', b'/', 0xff, 0xfe, b'.', b's', b'l', b'p', b'c',
343        ]));
344        let r = Request::Open {
345            container: name,
346            voice: Voice::Client,
347        };
348        assert_eq!(round_trip(&r), r);
349    }
350
351    #[test]
352    fn a_response_survives_the_wire() {
353        let mut wire = Vec::new();
354        answer(&mut wire, &Response::Ok(vec!["one".into(), "two".into()])).unwrap();
355        let mut c = Cursor::new(wire);
356        let mut both = Pair {
357            to_them: Vec::new(),
358            from_them: Cursor::new(Vec::new()),
359        };
360        std::io::copy(&mut c, &mut both.to_them).unwrap();
361        both.from_them = Cursor::new(both.to_them.clone());
362        // Read it back through the response decoder.
363        let fields = super::read_frame(&mut both.from_them).unwrap();
364        assert_eq!(
365            super::Response::from_fields(&fields).unwrap(),
366            Response::Ok(vec!["one".into(), "two".into()])
367        );
368    }
369
370    #[test]
371    fn an_error_response_carries_its_reason() {
372        let mut wire = Vec::new();
373        answer(&mut wire, &Response::Err("pdf is on the deny list".into())).unwrap();
374        let fields = super::read_frame(&mut Cursor::new(wire)).unwrap();
375        assert_eq!(
376            super::Response::from_fields(&fields).unwrap(),
377            Response::Err("pdf is on the deny list".into())
378        );
379    }
380
381    #[test]
382    fn a_frame_longer_than_the_cap_is_refused_before_it_is_allocated() {
383        // The length is a number the other end chose. A local process that can
384        // reach the endpoint should not be able to ask for a gigabyte.
385        let mut wire = Vec::new();
386        wire.extend_from_slice(&u32::MAX.to_be_bytes());
387        match super::read_frame(&mut Cursor::new(wire)) {
388            Err(Error::TooLong(n)) => assert_eq!(n, u32::MAX as usize),
389            other => panic!("{other:?}"),
390        }
391    }
392
393    #[test]
394    fn a_verb_this_build_does_not_know_is_refused_rather_than_guessed() {
395        let mut wire = Vec::new();
396        super::write_frame(&mut wire, &[b"drop-everything".to_vec()]).unwrap();
397        assert!(matches!(
398            take(&mut Cursor::new(wire)),
399            Err(Error::Malformed(_))
400        ));
401    }
402
403    #[test]
404    fn a_known_verb_with_the_wrong_shape_is_refused() {
405        // `open` with no path, and `close` with two ids. Both are a caller this
406        // build does not understand, and guessing at either would act on
407        // something nobody asked for.
408        for fields in [
409            vec![b"open".to_vec()],
410            vec![b"close".to_vec(), b"a".to_vec(), b"b".to_vec()],
411        ] {
412            let mut wire = Vec::new();
413            super::write_frame(&mut wire, &fields).unwrap();
414            assert!(matches!(
415                take(&mut Cursor::new(wire)),
416                Err(Error::Malformed(_))
417            ));
418        }
419    }
420
421    #[test]
422    fn a_connection_that_ends_mid_frame_is_an_error_and_not_a_hang() {
423        let mut wire = Vec::new();
424        super::write_frame(&mut wire, &Request::List.fields()).unwrap();
425        wire.truncate(wire.len() - 1);
426        assert!(matches!(take(&mut Cursor::new(wire)), Err(Error::Io(_))));
427    }
428
429    #[test]
430    fn asking_writes_a_request_and_reads_the_answer() {
431        let mut server_side = Vec::new();
432        answer(&mut server_side, &Response::Ok(vec!["fine".into()])).unwrap();
433        let mut pair = Pair {
434            to_them: Vec::new(),
435            from_them: Cursor::new(server_side),
436        };
437        assert_eq!(
438            ask(&mut pair, &Request::Ping).unwrap(),
439            Response::Ok(vec!["fine".into()])
440        );
441        // And the request really went out.
442        assert_eq!(take(&mut Cursor::new(pair.to_them)).unwrap(), Request::Ping);
443    }
444}