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}