Skip to main content

slipcase_open/
extract.rs

1//! Putting the payload where the target application can open it.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 5, steps 3 and 4: the payload is written into the session's own
7//! `payload/` directory (concept 6.4) and then carries whatever the platform
8//! records about where the container came from.
9//!
10//! **A payload the mark could not be carried onto does not survive this
11//! function.** `slpc::provenance::carry` fails only where the platform gates
12//! opening on a mark, the container has one, and the copy would end up with
13//! none — which is to say, only where leaving the file would produce the one
14//! thing this step exists to prevent: a document that opens without the warning
15//! its origin earned. `slipcase unpack` takes the same line, and this has the
16//! stronger obligation, because it is about to hand the file to the system
17//! itself rather than leave it on disk for somebody else to double-click.
18//!
19//! **The window between placing the file and marking it is closed by the
20//! directory rather than by ordering.** `Destination::in_place` carries a mark
21//! before its rename precisely so that no unmarked file is ever reachable under
22//! the final name; `Destination::new` cannot, because a caller naming an output
23//! file is creating one and the library does not know which container the bytes
24//! came from. Here the file lands inside a session directory that is the user's
25//! own and owner-only, nothing has been launched yet, and the mark is on before
26//! anything is told the payload exists.
27
28use std::fmt;
29use std::io::{Read, Seek};
30use std::path::Path;
31
32use slpc::provenance::Mark;
33use slpc::{Container, Destination};
34
35use crate::session::Session;
36
37/// Why a payload did not reach the session directory.
38#[derive(Debug)]
39pub enum Error {
40    /// The payload could not be read out of the container: encrypted, stored
41    /// with a compression method this build carries no decoder for, or a
42    /// container declaring a version it does not implement.
43    Unreadable(slpc::Error),
44    /// The payload could not be written.
45    Write(slpc::Error),
46    /// Where the container came from could not be carried onto the payload, so
47    /// opening the payload would not raise the warning opening the container
48    /// would have.
49    ///
50    /// Untested, and this says so rather than implying otherwise: reaching it
51    /// needs a platform that gates opening on a mark and then refuses the
52    /// write, which Linux does not do — it keeps provenance as a note. The
53    /// arms that can be reached here are, and `flow::open` re-asks the
54    /// filesystem before reporting `payload_removed`, because the sentence has
55    /// to be true when it is printed rather than when it was built.
56    Unmarked {
57        /// What the carry reported.
58        cause: slpc::Error,
59        /// Whether the payload was removed again. False means it is on disk
60        /// and ungated, which the caller has to say out loud.
61        payload_removed: bool,
62    },
63}
64
65impl fmt::Display for Error {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Unreadable(e) => write!(f, "the payload cannot be read: {e}"),
69            Self::Write(e) => write!(f, "the payload could not be written: {e}"),
70            Self::Unmarked {
71                cause,
72                payload_removed,
73            } => write!(
74                f,
75                "where the container came from could not be carried onto its payload: {cause}\n\
76                 The payload {}, because opening it would not raise the warning the container \
77                 would have.",
78                if *payload_removed {
79                    "has been removed"
80                } else {
81                    "could not be removed either and is ungated"
82                }
83            ),
84        }
85    }
86}
87
88impl std::error::Error for Error {}
89
90/// Write the payload into `session`, then carry the container's mark onto it.
91///
92/// The container's path is taken from the session's own record rather than
93/// passed in again, so that what gets marked from is the resolved path the
94/// session was opened against.
95///
96/// The payload gets the permissions a newly created file would ordinarily
97/// receive. SPEC 3 requires that, and forbids applying the bits the archive
98/// records — a conformant container may say setuid, and honouring it would put
99/// a setuid file on disk. `Destination::new` is what supplies the umask's
100/// answer, and it never consults `payload_mode`.
101///
102/// # Errors
103///
104/// See [`Error`]. Every variant means the payload must not be launched, and the
105/// [`Error::Unmarked`] one means it must not be left behind either.
106pub fn extract<R: Read + Seek>(
107    container: &mut Container<R>,
108    session: &mut Session,
109) -> Result<Mark, Error> {
110    // Asked before anything is written, so an encrypted payload or one stored
111    // by a method this build cannot decode is a sentence rather than a
112    // half-written file. `slipcase-desktop` asks the same question before it
113    // offers the button.
114    container
115        .check_payload_readable()
116        .map_err(|u| Error::Unreadable(u.into()))?;
117
118    let out = session.payload_path();
119    // No-clobber. The session directory was made for this payload a moment ago,
120    // so anything already under the name is something else's, and SPEC 3
121    // forbids replacing a file the caller did not ask to replace.
122    let mut dest = Destination::new(&out, false).map_err(Error::Write)?;
123    {
124        let mut payload = container.payload().map_err(Error::Unreadable)?;
125        std::io::copy(&mut payload, dest.writer()).map_err(|e| Error::Write(e.into()))?;
126    }
127    dest.commit().map_err(Error::Write)?;
128
129    // The first of the two moments the session and its container are known to
130    // agree, and this is where it is established, so this is where it is
131    // written down. Recovery needs it to tell an edit that never landed from a
132    // container that moved underneath a dead session.
133    //
134    // Best effort: a session that could not note it asks on recovery instead of
135    // acting, which is the cautious direction and no reason to fail an
136    // extraction that succeeded.
137    if let Ok(crc) = container.payload_crc() {
138        let _ = session.note_agreement(crc);
139    }
140
141    match slpc::provenance::carry(&session.record().container, &out) {
142        Ok(mark) => Ok(mark),
143        Err(cause) => Err(Error::Unmarked {
144            payload_removed: remove(&out),
145            cause,
146        }),
147    }
148}
149
150/// Take the payload back off disk. Reported rather than propagated: the caller
151/// is already failing, and whether the ungated file is still there changes what
152/// the sentence has to say rather than whether there is one.
153fn remove(at: &Path) -> bool {
154    std::fs::remove_file(at).is_ok()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{extract, Error};
160    use crate::session;
161    use std::fs;
162    use std::path::{Path, PathBuf};
163
164    /// A container on disk holding `payload` under `name`.
165    fn container(at: &Path, name: &str, payload: &[u8]) -> PathBuf {
166        let doc: slpc::toml_edit::DocumentMut =
167            format!("slipcase_version = \"1.0\"\n\n[payload]\nfile = \"{name}\"\n")
168                .parse()
169                .unwrap();
170        let path = at.join(format!("{name}.slpc"));
171        let out = fs::File::create(&path).unwrap();
172        slpc::pack_reader(name, payload, doc, out).unwrap();
173        path
174    }
175
176    fn open(at: &Path) -> slpc::Container<fs::File> {
177        slpc::Container::open(at).unwrap()
178    }
179
180    #[test]
181    fn the_payload_lands_under_its_own_name_with_its_own_bytes() {
182        let tmp = tempfile::tempdir().unwrap();
183        let root = tmp.path().join("sessions");
184        let c = container(tmp.path(), "report.pdf", b"%PDF-1.7 not really\n");
185
186        let mut s = session::create(&root, &c, "report.pdf").unwrap();
187        extract(&mut open(&c), &mut s).unwrap();
188
189        assert_eq!(s.payload_path().file_name().unwrap(), "report.pdf");
190        assert_eq!(
191            fs::read(s.payload_path()).unwrap(),
192            b"%PDF-1.7 not really\n"
193        );
194    }
195
196    #[test]
197    fn a_zero_length_payload_is_written_rather_than_refused() {
198        // SPEC 2.3 permits one, and a container holding nothing is still a
199        // container somebody wants opened.
200        let tmp = tempfile::tempdir().unwrap();
201        let root = tmp.path().join("sessions");
202        let c = container(tmp.path(), "empty.txt", b"");
203
204        let mut s = session::create(&root, &c, "empty.txt").unwrap();
205        extract(&mut open(&c), &mut s).unwrap();
206        assert_eq!(fs::read(s.payload_path()).unwrap(), b"");
207    }
208
209    #[test]
210    fn the_payload_is_the_only_thing_written_into_the_payload_directory() {
211        // Concept 6.1 reads anything else appearing there as the target
212        // application's work, and that inference is what the sibling signal
213        // rests on. SPEC 3 says the same from the other side: nothing but the
214        // payload is written when extracting.
215        let tmp = tempfile::tempdir().unwrap();
216        let root = tmp.path().join("sessions");
217        let c = container(tmp.path(), "report.pdf", b"x");
218
219        let mut s = session::create(&root, &c, "report.pdf").unwrap();
220        extract(&mut open(&c), &mut s).unwrap();
221
222        let mut found: Vec<_> = fs::read_dir(s.payload_dir())
223            .unwrap()
224            .map(|e| e.unwrap().file_name())
225            .collect();
226        found.sort();
227        assert_eq!(found, ["report.pdf"]);
228    }
229
230    #[test]
231    fn extracting_twice_into_one_session_refuses_rather_than_replaces() {
232        // SPEC 3 forbids replacing a file the caller did not ask to replace,
233        // and a second extraction into a live session would be overwriting a
234        // payload somebody may be editing.
235        let tmp = tempfile::tempdir().unwrap();
236        let root = tmp.path().join("sessions");
237        let c = container(tmp.path(), "report.pdf", b"first");
238
239        let mut s = session::create(&root, &c, "report.pdf").unwrap();
240        extract(&mut open(&c), &mut s).unwrap();
241        fs::write(s.payload_path(), b"edited by somebody").unwrap();
242
243        assert!(matches!(
244            extract(&mut open(&c), &mut s),
245            Err(Error::Write(_))
246        ));
247        assert_eq!(fs::read(s.payload_path()).unwrap(), b"edited by somebody");
248    }
249
250    #[test]
251    fn a_container_that_arrived_from_elsewhere_marks_the_payload_it_yields() {
252        // The reason this step exists. Unpacking without it is laundering: the
253        // payload reaches its handler as something this machine made, and the
254        // warning the platform would have shown never appears.
255        //
256        // The answer differs by platform and the assertion is that it is not
257        // `Silent` rather than which of the others it is. Linux keeps
258        // provenance as a note rather than a gate, so `Noted` is the right
259        // answer there and would be the wrong one on Windows.
260        let tmp = tempfile::tempdir().unwrap();
261        let root = tmp.path().join("sessions");
262        let c = container(tmp.path(), "report.pdf", b"%PDF");
263
264        // Not skipped quietly where the filesystem will not hold an attribute:
265        // a test that no-ops on the machine it runs on proves nothing, and this
266        // is the arm that matters most.
267        assert!(
268            testsupport::mark_as_downloaded(&c),
269            "this filesystem would not hold the mark, so the carry is untested here"
270        );
271
272        let mut s = session::create(&root, &c, "report.pdf").unwrap();
273        let mark = extract(&mut open(&c), &mut s).unwrap();
274        assert_ne!(mark, slpc::provenance::Mark::Silent);
275        assert!(slpc::provenance::arrived_from_elsewhere(&s.payload_path()));
276    }
277
278    #[test]
279    fn a_container_that_says_nothing_yields_a_payload_that_says_nothing() {
280        let tmp = tempfile::tempdir().unwrap();
281        let root = tmp.path().join("sessions");
282        let c = container(tmp.path(), "report.pdf", b"%PDF");
283
284        let mut s = session::create(&root, &c, "report.pdf").unwrap();
285        let mark = extract(&mut open(&c), &mut s).unwrap();
286        assert_eq!(mark, slpc::provenance::Mark::Silent);
287    }
288
289    #[test]
290    fn an_unreadable_payload_is_refused_before_anything_is_written() {
291        // An encrypted payload, which SPEC 2.5 forbids rejecting the container
292        // over and which this build cannot decode. The refusal is a sentence
293        // rather than a half-written file in the session directory.
294        let tmp = tempfile::tempdir().unwrap();
295        let root = tmp.path().join("sessions");
296        let c = tmp.path().join("locked.slpc");
297        fs::write(&c, encrypted_container()).unwrap();
298
299        let mut s = session::create(&root, &c, "secret.pdf").unwrap();
300        assert!(matches!(
301            extract(&mut open(&c), &mut s),
302            Err(Error::Unreadable(_))
303        ));
304        assert!(!s.payload_path().exists());
305        assert_eq!(fs::read_dir(s.payload_dir()).unwrap().count(), 0);
306    }
307
308    /// A container whose payload sets general purpose bit 0, which is what
309    /// `check_payload_readable` refuses. Built by hand, because the writer will
310    /// not produce one.
311    fn encrypted_container() -> Vec<u8> {
312        let doc = "slipcase_version = \"1.0\"\n\n[payload]\nfile = \"secret.pdf\"\n";
313        let mut bytes = Vec::new();
314        {
315            let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes));
316            let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
317            w.start_file(slpc::METADATA_MEMBER, opts).unwrap();
318            std::io::Write::write_all(&mut w, doc.as_bytes()).unwrap();
319            w.start_file("secret.pdf", opts).unwrap();
320            std::io::Write::write_all(&mut w, b"ciphertext").unwrap();
321            w.finish().unwrap();
322        }
323        // Set general purpose bit 0 in both the local header and the central
324        // directory entry, which is where `entries_of` reads it from.
325        set_encrypted_flag(&mut bytes);
326        bytes
327    }
328
329    /// Turn on general purpose bit 0 on the payload's local file header and its
330    /// central directory entry, and on neither of the metadata member's.
331    ///
332    /// The name is read at the offset the header says it is at, rather than
333    /// searched for. Searching finds `secret.pdf` after the metadata header too,
334    /// which sets the flag on the metadata member and makes the container
335    /// undetermined under SPEC 2.2 instead of holding an unreadable payload —
336    /// a different refusal, arriving before the one this test is about.
337    fn set_encrypted_flag(bytes: &mut [u8]) {
338        // (signature, flag offset, name-length offset, name offset)
339        for (signature, flag, len_at, name_at) in [
340            ([0x50u8, 0x4b, 0x03, 0x04], 6usize, 26usize, 30usize),
341            ([0x50, 0x4b, 0x01, 0x02], 8, 28, 46),
342        ] {
343            for i in 0..bytes.len().saturating_sub(name_at) {
344                if bytes[i..i + 4] != signature {
345                    continue;
346                }
347                let n = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize;
348                let from = i + name_at;
349                if bytes.get(from..from + n) == Some(b"secret.pdf".as_slice()) {
350                    bytes[i + flag] |= 0x01;
351                }
352            }
353        }
354    }
355}