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}