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}