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