Skip to main content

slipcase_open/
writeback.rs

1//! Putting an edited payload back into the container.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 7. Repack to a temporary container beside the original and swap it
7//! over: never modify in place, because an interruption mid-write corrupts the
8//! only copy.
9//!
10//! **`slpc::Destination::in_place` is the swap, and reimplementing it would be
11//! a regression.** It resolves the path first, so a container reached through a
12//! symbolic link is replaced rather than the link; it takes the replacement's
13//! permissions from the file being replaced rather than from the umask; and its
14//! `commit` carries the platform's mark — Mark of the Web, `com.apple.quarantine`
15//! — onto the replacement *before* the rename, failing the commit if it cannot,
16//! so a marked container is never replaced by an unmarked one.
17//!
18//! The naive version looks correct and fails quietly. A plain `std::fs::rename`
19//! is `MoveFileEx`, which carries over neither the target's ACLs nor its
20//! alternate data streams, and Mark of the Web is an alternate data stream. The
21//! repack-and-rename anyone would write first strips the container's trust zone
22//! on the first save, with no error and no visible symptom.
23//!
24//! **The container is read back before it replaces anything.** `slipcase
25//! repack` does this too, and this has more reason to: it runs unattended and
26//! repeatedly, so a fault that would cost one person one container there costs
27//! every save here.
28//!
29//! **The metadata member is not touched.** SPEC 5 defines no checksum or fixity
30//! key and 2.2 assigns no meaning to any key beyond `slipcase_version` and
31//! `payload.file`, so a changed payload falsifies nothing a conformant container
32//! says about itself. A producer may have recorded its own size or digest under
33//! a private key permitted by 2.5, and since the specification gives those keys
34//! no meaning this cannot know which, what it covers, or how it is encoded.
35//! Guessing is worse than leaving it: a wrong digest is a false claim, where a
36//! stale one is at least a claim whose provenance is the producer's.
37
38use std::fmt;
39use std::fs::File;
40
41use slpc::Destination;
42
43use crate::session::Session;
44
45/// Why an edit did not reach the container.
46#[derive(Debug)]
47pub enum Error {
48    /// The payload could not be read out of the session directory.
49    Payload(std::io::Error),
50    /// The container could not be read, or is no longer where the session
51    /// recorded it. Concept 6.4: a container may move or go while a session
52    /// runs, and this is not a failure of the edit.
53    Container(std::io::Error),
54    /// The file at the recorded path is not the container this session was
55    /// opened against — its payload goes by another name. Writing back would
56    /// rename the payload of a container somebody else may be holding, so it
57    /// refuses. Concept 6.3 asks the same question on the recovery side; this
58    /// is the guard on the acting side, and it belongs here because it is a
59    /// safety property of the write-back rather than an optimisation in
60    /// whatever called it.
61    ContainerChanged {
62        /// What the session recorded.
63        recorded: String,
64        /// What the file at that path says now.
65        found: String,
66    },
67    /// The repack itself failed. Nothing was replaced.
68    Repack(slpc::Error),
69    /// What the repack produced was not a conformant container, so it was not
70    /// allowed to replace one. Nothing was changed.
71    WouldNotBeConformant(String),
72    /// The replacement could not be put in place. Includes the case concept 7
73    /// cares most about: the container carries a mark that could not be carried
74    /// onto its replacement, which stops the commit rather than silently
75    /// laundering it.
76    Swap(slpc::Error),
77}
78
79impl fmt::Display for Error {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Payload(e) => write!(f, "the edited payload could not be read: {e}"),
83            Self::Container(e) => write!(f, "the container could not be opened: {e}"),
84            Self::ContainerChanged { recorded, found } => write!(
85                f,
86                "the container now holds {found} rather than {recorded}, so this is not the \
87                 container this session was opened against. Nothing was changed."
88            ),
89            Self::Repack(e) => write!(f, "the container could not be rebuilt: {e}"),
90            Self::WouldNotBeConformant(v) => write!(
91                f,
92                "the container this would have written is {v}. Nothing was changed."
93            ),
94            Self::Swap(e) => write!(
95                f,
96                "the rebuilt container could not replace the original: {e}"
97            ),
98        }
99    }
100}
101
102impl std::error::Error for Error {}
103
104/// Put the session's payload back into its container, and count it.
105///
106/// Unrecognised members survive, which `Repack` already guarantees and SPEC 3
107/// requires. The payload keeps the name the session recorded, so a container
108/// whose `payload.file` says one thing does not quietly acquire another.
109///
110/// # Errors
111///
112/// See [`Error`]. In every variant the original container is untouched.
113pub fn write_back(session: &mut Session) -> Result<(), Error> {
114    let container = session.record().container.clone();
115    let payload_path = session.payload_path();
116
117    let edited = File::open(&payload_path).map_err(Error::Payload)?;
118
119    // Asked before anything is written. A different container at the recorded
120    // path is not a container to repack into: the payload would be renamed to
121    // this session's `payload.file`, which is a change nobody asked for made to
122    // a file this session was never opened against.
123    let found = slpc::Container::open(&container)
124        .map_err(|e| match e {
125            slpc::Error::Io(e) => Error::Container(e),
126            other => Error::Repack(other),
127        })?
128        .payload_name()
129        .to_string();
130    if found != session.record().payload {
131        return Err(Error::ContainerChanged {
132            recorded: session.record().payload.clone(),
133            found,
134        });
135    }
136
137    let source = File::open(&container).map_err(Error::Container)?;
138
139    // `in_place` resolves the path and reads the mode off the file it is going
140    // to replace, so it is opened before the source is consumed rather than
141    // after — the two are independent, and doing it here keeps the failure that
142    // means *this directory is not writable* ahead of the work.
143    let mut out = Destination::in_place(&container).map_err(Error::Swap)?;
144
145    // `write` consumes the repack and with it the source handle, so the
146    // container is closed before the commit renames over it. That ordering is
147    // not cosmetic on Windows, where replacing a file somebody still holds open
148    // is the case that fails.
149    slpc::Repack::new(source)
150        .payload(&session.record().payload, edited)
151        .write(out.writer())
152        .map_err(Error::Repack)?;
153
154    let verdict = slpc::validate(out.written().map_err(Error::Repack)?).map_err(Error::Repack)?;
155    if !verdict.is_conformant() {
156        return Err(Error::WouldNotBeConformant(verdict.to_string()));
157    }
158    out.commit().map_err(Error::Swap)?;
159
160    // The container now holds what the payload holds, which is the second of
161    // the two moments the two sides are known to agree. Read back off the
162    // container rather than computed from the payload, so the value recorded is
163    // the one recovery will later compare against and cannot be a near miss.
164    //
165    // Best effort: a session that wrote back successfully and could not note it
166    // is a session that will ask on recovery instead of acting, which is the
167    // cautious direction and not worth failing a completed write-back over.
168    if let Ok(repacked) = slpc::Container::open(&container) {
169        if let Ok(crc) = repacked.payload_crc() {
170            let _ = session.note_agreement(crc);
171        }
172    }
173
174    session.note_write_back().map_err(Error::Payload)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::{write_back, Error};
180    use crate::{extract, session};
181    use std::fs;
182    use std::path::{Path, PathBuf};
183
184    fn container_with(at: &Path, name: &str, payload: &[u8], extra: &str) -> PathBuf {
185        let doc: slpc::toml_edit::DocumentMut =
186            format!("slipcase_version = \"1.0\"\n{extra}\n[payload]\nfile = \"{name}\"\n")
187                .parse()
188                .unwrap();
189        let path = at.join(format!("{name}.slpc"));
190        slpc::pack_reader(name, payload, doc, fs::File::create(&path).unwrap()).unwrap();
191        path
192    }
193
194    /// A session with the payload already extracted into it.
195    fn opened(root: &Path, container: &Path, name: &str) -> session::Session {
196        let mut s = session::create(root, container, name).unwrap();
197        extract::extract(&mut slpc::Container::open(container).unwrap(), &mut s).unwrap();
198        s
199    }
200
201    fn payload_of(container: &Path) -> Vec<u8> {
202        let mut c = slpc::Container::open(container).unwrap();
203        let mut out = Vec::new();
204        std::io::copy(&mut c.payload().unwrap(), &mut out).unwrap();
205        out
206    }
207
208    #[test]
209    fn an_edit_reaches_the_container() {
210        let tmp = tempfile::tempdir().unwrap();
211        let root = tmp.path().join("sessions");
212        let c = container_with(tmp.path(), "report.pdf", b"first", "");
213
214        let mut s = opened(&root, &c, "report.pdf");
215        fs::write(s.payload_path(), b"edited").unwrap();
216        write_back(&mut s).unwrap();
217
218        assert_eq!(payload_of(&c), b"edited");
219    }
220
221    #[test]
222    fn the_metadata_member_is_returned_byte_for_byte() {
223        // Concept 7. SPEC 5 defines no fixity key and 2.2 gives no meaning to
224        // any other, so a changed payload falsifies nothing — and a private key
225        // a producer used under 2.5 is one this cannot interpret, so leaving it
226        // is the only honest option.
227        let tmp = tempfile::tempdir().unwrap();
228        let root = tmp.path().join("sessions");
229        let extra = "producer = \"something else\"\nsha256 = \"stale after this edit\"\n";
230        let c = container_with(tmp.path(), "report.pdf", b"first", extra);
231
232        let before = slpc::Container::open(&c).unwrap().metadata_bytes().to_vec();
233        let mut s = opened(&root, &c, "report.pdf");
234        fs::write(s.payload_path(), b"edited").unwrap();
235        write_back(&mut s).unwrap();
236
237        let after = slpc::Container::open(&c).unwrap().metadata_bytes().to_vec();
238        assert_eq!(before, after);
239    }
240
241    #[test]
242    fn the_payload_keeps_the_name_the_session_recorded() {
243        let tmp = tempfile::tempdir().unwrap();
244        let root = tmp.path().join("sessions");
245        let c = container_with(tmp.path(), "report.pdf", b"first", "");
246
247        let mut s = opened(&root, &c, "report.pdf");
248        fs::write(s.payload_path(), b"edited").unwrap();
249        write_back(&mut s).unwrap();
250
251        assert_eq!(
252            slpc::Container::open(&c).unwrap().payload_name(),
253            "report.pdf"
254        );
255    }
256
257    #[test]
258    fn each_write_back_is_counted_on_disk() {
259        let tmp = tempfile::tempdir().unwrap();
260        let root = tmp.path().join("sessions");
261        let c = container_with(tmp.path(), "report.pdf", b"first", "");
262
263        let mut s = opened(&root, &c, "report.pdf");
264        for n in 1..=3 {
265            fs::write(s.payload_path(), format!("edit {n}")).unwrap();
266            write_back(&mut s).unwrap();
267            assert_eq!(session::scan(&root).unwrap()[0].record().write_backs, n);
268        }
269        assert_eq!(payload_of(&c), b"edit 3");
270    }
271
272    #[test]
273    fn writing_back_repeatedly_leaves_one_container_and_no_debris() {
274        // The temporary the swap goes through lives beside the container,
275        // because `ReplaceFileW` and `rename` both need the same volume. It
276        // must not survive the commit.
277        let tmp = tempfile::tempdir().unwrap();
278        let root = tmp.path().join("sessions");
279        let c = container_with(tmp.path(), "report.pdf", b"first", "");
280
281        let mut s = opened(&root, &c, "report.pdf");
282        for n in 0..5 {
283            fs::write(s.payload_path(), format!("{n}")).unwrap();
284            write_back(&mut s).unwrap();
285        }
286
287        let beside: Vec<_> = fs::read_dir(tmp.path())
288            .unwrap()
289            .map(|e| e.unwrap().file_name())
290            .filter(|n| n != "sessions")
291            .collect();
292        assert_eq!(beside, ["report.pdf.slpc"]);
293    }
294
295    #[test]
296    fn a_container_that_went_away_is_reported_rather_than_recreated() {
297        // Concept 6.4: a container may move or be deleted while a session runs.
298        // Writing a fresh one where it used to be would be inventing a file the
299        // user deleted.
300        let tmp = tempfile::tempdir().unwrap();
301        let root = tmp.path().join("sessions");
302        let c = container_with(tmp.path(), "report.pdf", b"first", "");
303
304        let mut s = opened(&root, &c, "report.pdf");
305        fs::write(s.payload_path(), b"edited").unwrap();
306        fs::remove_file(&c).unwrap();
307
308        assert!(matches!(write_back(&mut s), Err(Error::Container(_))));
309        assert!(!c.exists());
310    }
311
312    #[test]
313    fn a_missing_payload_is_reported_and_the_container_is_left_alone() {
314        let tmp = tempfile::tempdir().unwrap();
315        let root = tmp.path().join("sessions");
316        let c = container_with(tmp.path(), "report.pdf", b"first", "");
317
318        let mut s = opened(&root, &c, "report.pdf");
319        fs::remove_file(s.payload_path()).unwrap();
320
321        assert!(matches!(write_back(&mut s), Err(Error::Payload(_))));
322        assert_eq!(payload_of(&c), b"first");
323    }
324
325    #[test]
326    fn a_container_reached_through_a_link_replaces_the_file_and_not_the_link() {
327        // `Destination::in_place` resolves first. Without that the link becomes
328        // a regular file and the container it pointed at is orphaned.
329        #[cfg(unix)]
330        {
331            let tmp = tempfile::tempdir().unwrap();
332            let root = tmp.path().join("sessions");
333            let real = container_with(tmp.path(), "report.pdf", b"first", "");
334            let link = tmp.path().join("link.slpc");
335            std::os::unix::fs::symlink(&real, &link).unwrap();
336
337            let mut s = opened(&root, &link, "report.pdf");
338            fs::write(s.payload_path(), b"edited").unwrap();
339            write_back(&mut s).unwrap();
340
341            assert_eq!(payload_of(&real), b"edited");
342        }
343    }
344
345    #[test]
346    fn a_marked_container_is_still_marked_after_a_write_back() {
347        // The defect concept 7 exists to name: a plain rename is `MoveFileEx`,
348        // which carries neither ACLs nor alternate data streams, and Mark of
349        // the Web is an alternate data stream. `Destination::in_place` carries
350        // it before the rename, so this survives — and fails the commit rather
351        // than launder it if it cannot.
352        let tmp = tempfile::tempdir().unwrap();
353        let root = tmp.path().join("sessions");
354        let c = container_with(tmp.path(), "report.pdf", b"first", "");
355        assert!(
356            testsupport::mark_as_downloaded(&c),
357            "this filesystem would not hold the mark, so the carry is untested here"
358        );
359
360        let mut s = opened(&root, &c, "report.pdf");
361        fs::write(s.payload_path(), b"edited").unwrap();
362        write_back(&mut s).unwrap();
363
364        assert!(slpc::provenance::arrived_from_elsewhere(&c));
365        assert_eq!(payload_of(&c), b"edited");
366    }
367}