Skip to main content

slipcase_open/
extract.rs

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