Skip to main content

slipcase_open/
recover.rs

1//! What to do with a session that outlived the process holding it.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 6.3, amended 2026-09-03. **Recovery writes back an edit whose
7//! container has not moved, and asks about everything else.**
8//!
9//! The rule it replaces was that recovery never writes back on its own, because
10//! the tool was not watching when the process died and so cannot tell a
11//! complete save from a half-written one. That risk is real and is not gone.
12//! What changed is the comparison it was being weighed against: the alternative
13//! is not safety, it is a question, and a question that goes unanswered loses
14//! the same edit more quietly. The person pressed Save; being asked afterwards
15//! to choose between *write back*, *discard* and *reveal the folder* is this
16//! tool's own failure handed back to them in vocabulary they never asked to
17//! learn.
18//!
19//! Two things keep the risk small. Most applications save by writing a sibling
20//! and renaming over the payload — the behaviour concept 6.1 already relies on
21//! to know the application is working — so the file is the old bytes or the
22//! complete new ones and not a prefix of either. And what is written back is
23//! reported rather than done silently, so an outcome that looks wrong is
24//! visible while the container is still open in front of somebody.
25//!
26//! **It only acts where it knows which side moved**, which is
27//! [`State::Edited`] and nothing else. Where the container has changed too,
28//! nobody but the person can say which copy is the one they want, and that is
29//! the question worth interrupting for.
30//!
31//! The ZIP central directory already stores a CRC-32 for the payload member, so
32//! recovery computes the CRC of the extracted payload and compares. Equal means
33//! nothing was lost. Different means an edit never landed.
34//!
35//! **Comparing against the container beats recording a digest of the payload.**
36//! A recorded value is a second copy of a fact and can drift from it, and the
37//! moment it gets consulted is after a crash, which is when a session record is
38//! least trustworthy. The container's own value needs nothing maintaining it:
39//! repacking recomputes it, so the comparison stays correct across every
40//! write-back in a session as a side effect of the write-backs themselves.
41//!
42//! **The one value the session does record is not that**, and the difference is
43//! the whole reason it is allowed. *Has the payload changed* is answerable from
44//! the container and is answered there. *Which side changed* is answerable from
45//! neither side, because both are only observable now and the question is about
46//! then — see [`crate::session::Record::agreed`], which notes what the container
47//! held at the two moments the two were made to agree and nothing else.
48//!
49//! **It is change detection and never fixity.** The question is whether the
50//! file changed, not whether it can be proved untampered — anybody able to
51//! write into the user's own owner-only session directory can do worse than
52//! forge a checksum. SPEC 5 declined to define a fixity key and nothing here
53//! becomes one.
54
55use std::fmt;
56use std::fs::File;
57use std::io::{self, Read};
58use std::path::Path;
59
60use crate::i18n::{fill, t};
61use crate::session::Session;
62
63/// What a session left behind turned out to be.
64#[derive(Debug)]
65pub enum State {
66    /// No payload in the session directory. The session died between being
67    /// created and being filled, so there is nothing to recover and nothing to
68    /// ask about.
69    NothingExtracted,
70    /// The payload still matches the one in the container. Nothing was lost:
71    /// clean up and say nothing.
72    Unchanged,
73    /// The payload differs from the one in the container, and the container is
74    /// still holding what this session last agreed with it about. So the
75    /// difference is this session's own edit and nobody else's, and it goes
76    /// back.
77    Edited,
78    /// The payload differs from the container *and* the container is not what
79    /// it was when the two last agreed. Both sides moved.
80    ///
81    /// **The one case worth interrupting for.** Writing back would throw away
82    /// whatever changed the container, and discarding would throw away the
83    /// edit; there is no answer here that is not somebody's decision. It is
84    /// also rare: it needs a second writer to the same container while this
85    /// session was not running.
86    Diverged,
87    /// The container is no longer where the session recorded it. Concept 6.4
88    /// requires surviving this rather than failing at the rename: the payload
89    /// is still here, and the person can be offered somewhere else to put it.
90    ContainerGone,
91    /// Something is at the recorded path, and it is not the container this
92    /// session was opened against — its payload goes by another name. Writing
93    /// back would rename the payload of a container somebody else's session may
94    /// be holding.
95    ContainerChanged {
96        /// What the session recorded.
97        recorded: String,
98        /// What the file at that path says now.
99        found: String,
100    },
101    /// The container is there and cannot be read, or the payload cannot be. A
102    /// question for a person rather than an answer.
103    Unreadable(String),
104}
105
106impl fmt::Display for State {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        // Translated, because this clause is spliced into sentences a person
109        // reads — *It is {state}* in a notification, and the third column of
110        // `sessions` — and an English clause inside a German sentence is worse
111        // than either language alone. It is prose about somebody's file rather
112        // than a state name anything matches on: `Course` is what the code
113        // decides from, and it is a separate method.
114        match self {
115            Self::NothingExtracted => write!(f, "{}", t("nothing was extracted")),
116            Self::Unchanged => write!(f, "{}", t("unchanged since it came out of the container")),
117            Self::Edited => write!(
118                f,
119                "{}",
120                t("edited, and the edit never reached the container")
121            ),
122            Self::Diverged => write!(
123                f,
124                "{}",
125                t("edited, and the container changed too, so both hold work the other does not")
126            ),
127            Self::ContainerGone => {
128                write!(f, "{}", t("the container is no longer where it was"))
129            }
130            Self::ContainerChanged { recorded, found } => write!(
131                f,
132                "{}",
133                fill(
134                    t("the container now holds {found} rather than {recorded}"),
135                    &[("found", found), ("recorded", recorded)],
136                )
137            ),
138            Self::Unreadable(e) => write!(
139                f,
140                "{}",
141                fill(t("cannot be read: {reason}"), &[("reason", e)])
142            ),
143        }
144    }
145}
146
147/// What recovery does about a session left behind, without being told.
148///
149/// Exhaustive over [`State`] on purpose. Two predicates would let a state added
150/// later fall into whichever bucket the negation happened to put it in, and the
151/// buckets here are *delete it*, *write to somebody's container* and *interrupt
152/// them* — three things that must never be picked by accident.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Course {
155    /// Nothing was lost. Remove it and say nothing (concept 6.3).
156    Sweep,
157    /// An edit that never reached a container that is still where it was. Put
158    /// it back, and say so.
159    WriteBack,
160    /// Only a person can settle it.
161    Ask,
162}
163
164impl State {
165    /// What to do about it.
166    #[must_use]
167    pub fn course(&self) -> Course {
168        match self {
169            Self::NothingExtracted | Self::Unchanged => Course::Sweep,
170            Self::Edited => Course::WriteBack,
171            Self::Diverged
172            | Self::ContainerGone
173            | Self::ContainerChanged { .. }
174            | Self::Unreadable(_) => Course::Ask,
175        }
176    }
177
178    /// Whether a person has to be asked about this one.
179    #[must_use]
180    pub fn needs_a_person(&self) -> bool {
181        self.course() == Course::Ask
182    }
183
184    /// Whether this one is recovery's own to put back.
185    #[must_use]
186    pub fn is_ours_to_write_back(&self) -> bool {
187        self.course() == Course::WriteBack
188    }
189
190    /// Whether it can be removed with nothing said.
191    ///
192    /// **Not `!needs_a_person()`**, which is what it used to be and what would
193    /// now sweep away an edit. See [`Course`].
194    #[must_use]
195    pub fn is_quiet(&self) -> bool {
196        self.course() == Course::Sweep
197    }
198}
199
200/// What became of a session left behind.
201#[must_use]
202pub fn state(session: &Session) -> State {
203    let payload = session.payload_path();
204    if !payload.is_file() {
205        return State::NothingExtracted;
206    }
207
208    let container = match slpc::Container::open(&session.record().container) {
209        Ok(c) => c,
210        Err(slpc::Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => {
211            return State::ContainerGone
212        }
213        Err(e) => return State::Unreadable(e.to_string()),
214    };
215
216    // Asked before the CRC, because a container holding a different payload
217    // answers the wrong question rather than answering it wrongly.
218    if container.payload_name() != session.record().payload {
219        return State::ContainerChanged {
220            recorded: session.record().payload.clone(),
221            found: container.payload_name().to_string(),
222        };
223    }
224
225    let (stored, made) = match (container.payload_crc(), crc_of(&payload)) {
226        (Ok(a), Ok(b)) => (a, b),
227        (Err(e), _) => return State::Unreadable(e.to_string()),
228        (_, Err(e)) => return State::Unreadable(e.to_string()),
229    };
230
231    if stored == made {
232        return State::Unchanged;
233    }
234
235    // The payload and the container disagree. Which of them moved is the whole
236    // question, and only the record answers it: `agreed` is what the container
237    // held the last time this session and it were made to agree.
238    //
239    // Not known — an older build, or a container unreadable at the time — is
240    // read as *not known to agree* and asks. That is the cautious direction and
241    // the one that cannot lose anything.
242    match session.record().agreed {
243        Some(agreed) if agreed == stored => State::Edited,
244        _ => State::Diverged,
245    }
246}
247
248/// The CRC-32 of a file on disk, computed the way the archive computed the one
249/// it recorded.
250///
251/// # Errors
252///
253/// Where the file cannot be read.
254pub fn crc_of(path: &Path) -> io::Result<u32> {
255    let mut file = File::open(path)?;
256    let mut hasher = crc32fast::Hasher::new();
257    // Streamed rather than read whole: a payload may be any size, and holding
258    // one in memory to checksum it would make recovery fail on the containers
259    // most worth recovering.
260    // On the heap. Sixty-four kilobytes of stack is a lot to ask of a thread
261    // whose size this crate does not choose.
262    let mut buf = vec![0u8; 64 * 1024];
263    loop {
264        match file.read(&mut buf)? {
265            0 => break,
266            n => hasher.update(&buf[..n]),
267        }
268    }
269    Ok(hasher.finalize())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{crc_of, state, Course, State};
275    use crate::{extract, session, writeback};
276    use std::fs;
277    use std::path::{Path, PathBuf};
278
279    fn container(at: &Path, name: &str, payload: &[u8]) -> PathBuf {
280        let doc: slpc::toml_edit::DocumentMut =
281            format!("slipcase_version = \"1.0\"\n\n[payload]\nfile = \"{name}\"\n")
282                .parse()
283                .unwrap();
284        let path = at.join(format!("{name}.slpc"));
285        slpc::pack_reader(name, payload, doc, fs::File::create(&path).unwrap()).unwrap();
286        path
287    }
288
289    fn opened(root: &Path, c: &Path, name: &str) -> session::Session {
290        let mut s = session::create(root, c, name).unwrap();
291        extract::extract(&mut slpc::Container::open(c).unwrap(), &mut s).unwrap();
292        s
293    }
294
295    #[test]
296    fn a_payload_nobody_touched_is_the_quiet_case() {
297        let tmp = tempfile::tempdir().unwrap();
298        let root = tmp.path().join("sessions");
299        let c = container(tmp.path(), "report.pdf", b"first");
300
301        let s = opened(&root, &c, "report.pdf");
302        assert!(matches!(state(&s), State::Unchanged));
303        assert!(!state(&s).needs_a_person());
304    }
305
306    #[test]
307    fn an_edit_that_never_landed_goes_back_without_being_asked_about() {
308        // The container is still holding what this session agreed with it
309        // about, so the difference is this session's own edit and nobody
310        // else's. There is nothing for a person to decide.
311        let tmp = tempfile::tempdir().unwrap();
312        let root = tmp.path().join("sessions");
313        let c = container(tmp.path(), "report.pdf", b"first");
314
315        let s = opened(&root, &c, "report.pdf");
316        fs::write(s.payload_path(), b"edited and then the process died").unwrap();
317        assert!(matches!(state(&s), State::Edited));
318        assert_eq!(state(&s).course(), Course::WriteBack);
319        assert!(!state(&s).needs_a_person());
320        assert!(!state(&s).is_quiet(), "it must not be swept away");
321    }
322
323    #[test]
324    fn an_edit_whose_container_also_moved_is_a_question() {
325        // Both sides changed, so writing back throws away whatever changed the
326        // container and discarding throws away the edit. Nobody but the person
327        // can pick.
328        let tmp = tempfile::tempdir().unwrap();
329        let root = tmp.path().join("sessions");
330        let c = container(tmp.path(), "report.pdf", b"first");
331
332        let s = opened(&root, &c, "report.pdf");
333        fs::write(s.payload_path(), b"our edit").unwrap();
334        // Somebody else repacked it while this session was not running.
335        container(tmp.path(), "report.pdf", b"somebody else's second thoughts");
336
337        assert!(matches!(state(&s), State::Diverged));
338        assert_eq!(state(&s).course(), Course::Ask);
339    }
340
341    #[test]
342    fn a_session_that_never_recorded_an_agreement_is_asked_about() {
343        // What an older build's session looks like, and the cautious reading of
344        // it: not known to agree is not the same as agreeing, so it asks rather
345        // than writing into a container it cannot vouch for.
346        let tmp = tempfile::tempdir().unwrap();
347        let root = tmp.path().join("sessions");
348        let c = container(tmp.path(), "report.pdf", b"first");
349
350        let s = opened(&root, &c, "report.pdf");
351        fs::write(s.payload_path(), b"edited").unwrap();
352        assert_eq!(state(&s).course(), Course::WriteBack);
353
354        // Strike the line an older build would never have written.
355        let record = s.dir().join("session.toml");
356        let text = fs::read_to_string(&record).unwrap();
357        let without: Vec<&str> = text.lines().filter(|l| !l.starts_with("agreed")).collect();
358        fs::write(&record, without.join("\n")).unwrap();
359        let reread = &session::scan(&root).unwrap()[0];
360
361        assert!(reread.record().agreed.is_none());
362        assert_eq!(state(reread).course(), Course::Ask);
363    }
364
365    #[test]
366    fn every_state_takes_exactly_one_course() {
367        // `Course` is exhaustive over `State` on purpose: the three outcomes
368        // are delete it, write to somebody's container, and interrupt them, and
369        // a state added later must not fall into one of those by whichever way
370        // a negation happened to go.
371        for (state, want) in [
372            (State::NothingExtracted, Course::Sweep),
373            (State::Unchanged, Course::Sweep),
374            (State::Edited, Course::WriteBack),
375            (State::Diverged, Course::Ask),
376            (State::ContainerGone, Course::Ask),
377            (
378                State::ContainerChanged {
379                    recorded: "a".into(),
380                    found: "b".into(),
381                },
382                Course::Ask,
383            ),
384            (State::Unreadable("why".into()), Course::Ask),
385        ] {
386            assert_eq!(state.course(), want, "{state:?}");
387            assert_eq!(state.is_quiet(), want == Course::Sweep, "{state:?}");
388            assert_eq!(state.needs_a_person(), want == Course::Ask, "{state:?}");
389            assert_eq!(
390                state.is_ours_to_write_back(),
391                want == Course::WriteBack,
392                "{state:?}"
393            );
394        }
395    }
396
397    #[test]
398    fn a_write_back_returns_the_session_to_quiet() {
399        // The property that makes comparing against the container work at all:
400        // repacking recomputes the stored CRC, so nothing has to maintain the
401        // comparison across a session's write-backs.
402        let tmp = tempfile::tempdir().unwrap();
403        let root = tmp.path().join("sessions");
404        let c = container(tmp.path(), "report.pdf", b"first");
405
406        let mut s = opened(&root, &c, "report.pdf");
407        fs::write(s.payload_path(), b"edited").unwrap();
408        assert!(matches!(state(&s), State::Edited));
409
410        writeback::write_back(&mut s).unwrap();
411        assert!(matches!(state(&s), State::Unchanged));
412    }
413
414    #[test]
415    fn a_session_that_died_before_extracting_has_nothing_to_ask_about() {
416        let tmp = tempfile::tempdir().unwrap();
417        let root = tmp.path().join("sessions");
418        let c = container(tmp.path(), "report.pdf", b"first");
419
420        let s = session::create(&root, &c, "report.pdf").unwrap();
421        assert!(matches!(state(&s), State::NothingExtracted));
422        assert!(!state(&s).needs_a_person());
423    }
424
425    #[test]
426    fn a_container_that_went_away_leaves_the_payload_worth_offering() {
427        let tmp = tempfile::tempdir().unwrap();
428        let root = tmp.path().join("sessions");
429        let c = container(tmp.path(), "report.pdf", b"first");
430
431        let s = opened(&root, &c, "report.pdf");
432        fs::write(s.payload_path(), b"edited").unwrap();
433        fs::remove_file(&c).unwrap();
434
435        assert!(matches!(state(&s), State::ContainerGone));
436        assert!(state(&s).needs_a_person());
437        assert!(s.payload_path().is_file());
438    }
439
440    #[test]
441    fn a_different_container_at_the_same_path_is_not_written_over() {
442        // Writing back here would rename the payload of a container this
443        // session was never opened against.
444        let tmp = tempfile::tempdir().unwrap();
445        let root = tmp.path().join("sessions");
446        let c = container(tmp.path(), "report.pdf", b"first");
447
448        let s = opened(&root, &c, "report.pdf");
449        fs::write(s.payload_path(), b"edited").unwrap();
450
451        // Something else entirely, at the path the session recorded.
452        let other = container(tmp.path(), "plan.dwg", b"unrelated");
453        fs::rename(&other, &c).unwrap();
454
455        match state(&s) {
456            State::ContainerChanged { recorded, found } => {
457                assert_eq!(recorded, "report.pdf");
458                assert_eq!(found, "plan.dwg");
459            }
460            other => panic!("{other:?}"),
461        }
462    }
463
464    #[test]
465    fn a_zero_length_payload_compares_rather_than_erroring() {
466        // SPEC 2.3 permits one, and CRC-32 of nothing is zero on both sides.
467        let tmp = tempfile::tempdir().unwrap();
468        let root = tmp.path().join("sessions");
469        let c = container(tmp.path(), "empty.txt", b"");
470
471        let s = opened(&root, &c, "empty.txt");
472        assert!(matches!(state(&s), State::Unchanged));
473
474        fs::write(s.payload_path(), b"no longer empty").unwrap();
475        assert!(matches!(state(&s), State::Edited));
476    }
477
478    #[test]
479    fn the_crc_is_streamed_rather_than_read_whole() {
480        // A payload may be any size, and a recovery that needs one in memory
481        // fails on the containers most worth recovering. Larger than the
482        // buffer, so the loop runs more than once.
483        let tmp = tempfile::tempdir().unwrap();
484        let big = tmp.path().join("big.bin");
485        let bytes = vec![0xa5u8; 300 * 1024];
486        fs::write(&big, &bytes).unwrap();
487        assert_eq!(crc_of(&big).unwrap(), crc32fast::hash(&bytes));
488    }
489}