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}