slipcase_open/session.rs
1//! Where a session lives on disk, and what it remembers.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 6.4. **Not the system temporary directory**, which is the obvious
7//! place and the wrong one: a reboot, a `tmpfiles` cleaner or Storage Sense may
8//! delete anything there, and doing so would destroy an edit the user has made
9//! and the tool has not yet written back — silently, in the window concept 6.3
10//! exists to survive.
11//!
12//! So a session is a directory under the application's own per-user state
13//! directory, and recovery is a scan of that one tree rather than a record
14//! pointing somewhere that may no longer be there.
15//!
16//! ## The payload sits one level down
17//!
18//! A session directory holds `session.toml` and a `payload/` directory, and the
19//! payload goes inside the latter under its own name. Two reasons, and the
20//! first is a collision: SPEC 2.3 permits any plain filename, `session.toml`
21//! included, so a payload beside the record could overwrite it. The second is
22//! that concept 6.1 reads *anything else in the directory* as the target
23//! application's doing, and that inference is only sound if the tool put
24//! exactly one file there.
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32/// The file inside a session directory that carries [`Record`].
33const RECORD: &str = "session.toml";
34
35/// The directory inside a session directory that carries the payload, and
36/// nothing this tool put there.
37const PAYLOAD_DIR: &str = "payload";
38
39/// How many names [`create`] will try before giving up. A thousand sessions
40/// opened inside one second is not a thing that happens, and a directory that
41/// somehow defeats the counter should say so rather than spin.
42const ATTEMPTS: u32 = 1024;
43
44/// What a session remembers across a crash.
45///
46/// Deliberately small. Concept 6.3 removed the *payload* digest this used to
47/// carry: the container records a CRC-32 for its payload already, so recovery
48/// compares against the container rather than against a second copy of the fact
49/// that can drift from it — and drift is likeliest at the moment this file is
50/// consulted.
51///
52/// [`Record::agreed`] is not that digest coming back, and the difference is
53/// worth being exact about. The removed one answered *has the payload changed*,
54/// which the container can answer better. This one answers *which side changed*,
55/// which nothing can answer without a record, because both sides are only
56/// visible now and the question is about then. It is a note of a past moment
57/// rather than a cached copy of a present fact, so there is nothing for it to
58/// drift from: if it is stale, the answer it gives — that the container is not
59/// where we left it — is the true one.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Record {
62 /// Where the container was when the session opened, resolved. It may have
63 /// moved or gone since, which concept 6.4 requires recovery to survive
64 /// rather than fail at the rename.
65 pub container: PathBuf,
66 /// The payload's name inside the container, which is also its name inside
67 /// `payload/` and therefore what decides which application opens it.
68 pub payload: String,
69 /// When the session opened, in seconds since the Unix epoch.
70 ///
71 /// A number rather than a formatted timestamp, because nothing in the tool
72 /// needs to render it: concept 6.3 shows a person the payload's own
73 /// modification time, which comes from the filesystem. Storing it this way
74 /// keeps a date-formatting dependency out of a crate that would otherwise
75 /// have no use for one.
76 pub started: u64,
77 /// How many write-backs this session has performed, which concept 6.2 shows
78 /// beside the session.
79 pub write_backs: u64,
80 /// The container's payload CRC-32 at the last moment this session and the
81 /// container were known to agree: the extraction, or the most recent
82 /// write-back.
83 ///
84 /// **What it is for is telling which side moved.** A payload that differs
85 /// from its container is either an edit that never landed or a container
86 /// that changed underneath a dead session, and those want opposite
87 /// treatment — the first is the person's own work and goes back, the second
88 /// is a conflict only they can settle. Comparing the two sides now cannot
89 /// separate them, because both are only observable in the present.
90 ///
91 /// `None` for a session written by a build that did not record it, and for
92 /// one whose container could not be read at the time. Recovery treats that
93 /// as *not known to agree*, which is the cautious direction: it asks.
94 pub agreed: Option<u32>,
95}
96
97/// An open or recoverable session on disk.
98#[derive(Debug, Clone)]
99pub struct Session {
100 dir: PathBuf,
101 record: Record,
102}
103
104impl Session {
105 /// The session's own directory.
106 #[must_use]
107 pub fn dir(&self) -> &Path {
108 &self.dir
109 }
110
111 /// What this session remembers.
112 #[must_use]
113 pub fn record(&self) -> &Record {
114 &self.record
115 }
116
117 /// The directory the payload sits in, and the one to watch. Concept 6.1
118 /// reads anything else appearing here as the target application's work.
119 #[must_use]
120 pub fn payload_dir(&self) -> PathBuf {
121 self.dir.join(PAYLOAD_DIR)
122 }
123
124 /// The payload itself.
125 #[must_use]
126 pub fn payload_path(&self) -> PathBuf {
127 self.payload_dir().join(&self.record.payload)
128 }
129
130 /// Count a write-back, and write that down before returning.
131 ///
132 /// Persisted rather than held, because the number is only worth anything to
133 /// a session that crashed, and a count kept in memory is a count lost by
134 /// the event it exists to describe.
135 ///
136 /// # Errors
137 ///
138 /// Where the record cannot be rewritten.
139 pub fn note_write_back(&mut self) -> io::Result<()> {
140 self.record.write_backs += 1;
141 write_record(&self.dir, &self.record)
142 }
143
144 /// Write down that the container's payload is this, and that it is what
145 /// this session's payload came from or was last put into.
146 ///
147 /// Called at the two moments the two sides are made to agree: the
148 /// extraction, and the commit of a write-back. Nowhere else — a value
149 /// recorded at any other moment would be recording an agreement that was
150 /// never established, which is the one way [`Record::agreed`] could tell a
151 /// lie rather than simply not know.
152 ///
153 /// # Errors
154 ///
155 /// Where the record cannot be rewritten.
156 pub fn note_agreement(&mut self, crc: u32) -> io::Result<()> {
157 self.record.agreed = Some(crc);
158 write_record(&self.dir, &self.record)
159 }
160
161 /// Remove the session and everything in it.
162 ///
163 /// # Errors
164 ///
165 /// Where the directory cannot be removed.
166 pub fn remove(self) -> io::Result<()> {
167 fs::remove_dir_all(&self.dir)
168 }
169}
170
171// **There was a retry here, and it was removed because what it waited for does
172// not pass.** `823a972` made `remove` keep trying for three hundred
173// milliseconds, on the strength of two observations: a session directory that
174// would not go, and the same directory going without complaint a minute later.
175// The second was read as the condition clearing on its own.
176//
177// It is not. The removals that failed were the packaged product's and the ones
178// that succeeded were another process's, and that difference is the whole
179// defect — see `platform_base` below, where the measurement is. A packaged
180// process asking for `%LOCALAPPDATA%` is given a redirected view it is not told
181// about, and a directory belonging to the layer underneath that view can never
182// be removed through it, however long anybody waits. Fifteen of them survived
183// twelve seconds of retrying.
184//
185// So the retry addressed nothing, and its doc comment asserted a cause that is
186// now measured false. `CLAUDE.md` says an unproven claim is worse than no
187// claim, because the next reader cannot tell it from a proven one; a disproven
188// one left in place is worse again. If a removal here is ever seen to fail
189// transiently for a reason somebody has measured, a retry comes back with that
190// measurement attached.
191
192/// The per-user state directory this build keeps sessions under.
193///
194/// `$XDG_STATE_HOME` on Linux, which is defined for state that must survive a
195/// restart without being configuration or data, and never `XDG_RUNTIME_DIR`,
196/// which is cleared at logout. `~/Library/Application Support` on macOS rather
197/// than `Caches`, which the system may purge at will. `%LOCALAPPDATA%` on
198/// Windows and deliberately not the roaming profile, since an extracted payload
199/// cannot follow a user between machines.
200///
201/// # Errors
202///
203/// Where the platform names no home for this, which is a machine too unusual to
204/// guess about rather than a condition to work around.
205pub fn default_root() -> io::Result<PathBuf> {
206 let base = platform_base().ok_or_else(|| {
207 io::Error::new(
208 io::ErrorKind::NotFound,
209 "no per-user state directory: set XDG_STATE_HOME, HOME, or LOCALAPPDATA",
210 )
211 })?;
212 Ok(base.join("slipcase-open").join("sessions"))
213}
214
215#[cfg(target_os = "linux")]
216fn platform_base() -> Option<PathBuf> {
217 if let Some(x) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
218 return Some(PathBuf::from(x));
219 }
220 // The fallback the XDG base directory specification names, rather than one
221 // of this project's choosing.
222 Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/state"))
223}
224
225#[cfg(target_os = "macos")]
226fn platform_base() -> Option<PathBuf> {
227 Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Application Support"))
228}
229
230/// `%LOCALAPPDATA%` where this process is an ordinary program, and the
231/// package's own store where it is a packaged one — asked of Windows rather
232/// than assumed.
233///
234/// **A packaged process that asks for `%LOCALAPPDATA%` does not get it, and is
235/// not told.** Measured on 2026-09-05 against the installed 0.1.4 package: with
236/// both roots emptied and a container opened through the shell verb, no
237/// `%LOCALAPPDATA%\slipcase-open` was created at all and the session appeared
238/// under `…\Packages\<family>\LocalCache\Local\slipcase-open\sessions`. MSIX
239/// redirects that variable, and the read view is *merged*, so the process also
240/// sees whatever is in the real location and cannot tell the two apart.
241///
242/// **That merge is what produced the sessions surviving their own removal**,
243/// which `PLAN.md` carried as an open defect with three explanations measured
244/// and found wrong. A redirection layer can tombstone a *file* in the layer
245/// beneath it and cannot remove a *directory* there, so `remove_dir_all`
246/// unlinked the payload and failed on `payload/` with `ERROR_SHARING_VIOLATION`
247/// — for ever, not transiently, and with no process holding anything. Measured
248/// the same day: the same executable, byte for byte, with the same package
249/// identity, removes the directory when it runs from a staging tree and never
250/// removes it when it runs from the package's install location; fifteen such
251/// directories survived twelve seconds of a packaged sweep retrying them, while
252/// any other process removed each one on the first ask.
253///
254/// So the answer is to stop asking for a path this process will not be given.
255/// `LocalCacheFolder` and not `LocalFolder`: both were measured to create and
256/// remove cleanly here, and the cache is the one Windows neither roams nor
257/// includes in a device backup, which is what a copy of somebody's payload
258/// should be — concept 17's backup-exposure question, settled on this platform
259/// by where the directory is rather than by a warning about it.
260#[cfg(target_os = "windows")]
261fn platform_base() -> Option<PathBuf> {
262 package_store().or_else(|| std::env::var_os("LOCALAPPDATA").map(PathBuf::from))
263}
264
265/// Where Windows says this package keeps its data, or `None` where there is no
266/// package.
267///
268/// **On a thread of its own, and that is not caution.** Answering this enters a
269/// COM apartment, and the launch path enters a single-threaded one later
270/// because `ShellExecuteEx` hands work to shell extensions that require it
271/// (`platform::shell`). A multi-threaded apartment entered here first would
272/// make that call fail with `RPC_E_CHANGED_MODE` and leave the launcher running
273/// in the wrong model — a real regression bought for a path lookup. A thread
274/// that exits takes its apartment with it.
275///
276/// **Identity is asked about before `WinRT` is touched, because without a package
277/// `ApplicationData::Current` does not fail — it crashes.** Measured while
278/// writing this: the suite died with `STATUS_ACCESS_VIOLATION` the first time
279/// this function ran in a test binary, which has no package. So the question
280/// *is there a package* is put to a plain Win32 call that answers it with an
281/// error code, and the `WinRT` call is only ever made where the answer was yes.
282/// `Toast::connect` gets away with the direct attempt because
283/// `CreateToastNotifier` refuses politely; this one does not, and the two are
284/// not interchangeable.
285#[cfg(target_os = "windows")]
286fn package_store() -> Option<PathBuf> {
287 if !packaged() {
288 return None;
289 }
290 std::thread::spawn(|| {
291 use windows::Storage::ApplicationData;
292 apartment();
293 let path = ApplicationData::Current()
294 .ok()?
295 .LocalCacheFolder()
296 .ok()?
297 .Path()
298 .ok()?;
299 Some(PathBuf::from(path.to_os_string()))
300 })
301 .join()
302 .ok()
303 .flatten()
304}
305
306/// Whether this process is running with package identity.
307///
308/// `GetCurrentPackageFamilyName` asked with no buffer: a packaged process is
309/// told the buffer is too small, and an unpackaged one is told there is no
310/// package. Nothing is read back, so the name itself is never needed — the
311/// question here is only which of those two answers comes.
312#[cfg(target_os = "windows")]
313#[allow(unsafe_code)]
314fn packaged() -> bool {
315 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
316 use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFamilyName;
317
318 let mut len: u32 = 0;
319 // SAFETY: the documented way to ask for the required length. The length is
320 // in-out and lives here; the buffer is null, which is what a zero length
321 // licenses, and nothing is written through it.
322 let how = unsafe { GetCurrentPackageFamilyName(&raw mut len, std::ptr::null_mut()) };
323 how == ERROR_INSUFFICIENT_BUFFER
324}
325
326/// A multi-threaded apartment for the calling thread, which `WinRT` activation
327/// needs and which this crate only ever enters on a thread it is about to
328/// throw away.
329#[cfg(target_os = "windows")]
330#[allow(unsafe_code)]
331fn apartment() {
332 use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
333 // SAFETY: the documented entry point, with no reserved parameter, on a
334 // thread this function owns. The result is ignored deliberately: `S_FALSE`
335 // means somebody had already entered on this thread, and there is nobody
336 // else on this one.
337 let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
338}
339
340#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
341fn platform_base() -> Option<PathBuf> {
342 None
343}
344
345/// Start a session under `root`, creating the tree if it is not there.
346///
347/// The container path is resolved before it is written down, so a container
348/// reached through a symbolic link records the file rather than the link, and
349/// so that what recovery reads later is a path and not a relative fragment
350/// interpreted against whatever directory a later process happens to be in.
351///
352/// # Errors
353///
354/// Where the container cannot be resolved, or the tree cannot be created.
355pub fn create(root: &Path, container: &Path, payload: &str) -> io::Result<Session> {
356 let container = fs::canonicalize(container)?;
357 let started = seconds_since_epoch();
358
359 create_private_dir_all(root)?;
360
361 // Named for when it started and made unique by the create itself, which is
362 // atomic. No randomness: the root is the user's own and private, and a
363 // counter that cannot collide is a smaller thing to get right than a source
364 // of entropy would be.
365 let mut made = None;
366 for n in 0..ATTEMPTS {
367 let candidate = root.join(format!("{started:x}-{n}"));
368 match fs::create_dir(&candidate) {
369 Ok(()) => {
370 made = Some(candidate);
371 break;
372 }
373 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
374 Err(e) => return Err(e),
375 }
376 }
377 let Some(dir) = made else {
378 return Err(io::Error::new(
379 io::ErrorKind::AlreadyExists,
380 format!("{ATTEMPTS} session directories already exist for this second"),
381 ));
382 };
383 private(&dir)?;
384
385 let session = Session {
386 record: Record {
387 container,
388 payload: payload.to_string(),
389 started,
390 write_backs: 0,
391 // Not known yet. `extract` is what sets it, because that is the
392 // moment the two are made to agree.
393 agreed: None,
394 },
395 dir,
396 };
397
398 create_private_dir_all(&session.payload_dir())?;
399 write_record(&session.dir, &session.record)?;
400 Ok(session)
401}
402
403/// Every session under `root`, open or left behind.
404///
405/// A directory carrying no readable record is skipped rather than reported: it
406/// is a session being created by another process right now, or the remains of
407/// one that died between the two operations, and neither is something to fail a
408/// recovery scan over.
409///
410/// # Errors
411///
412/// Where `root` exists and cannot be read. A `root` that is not there yet is an
413/// empty list, because a machine that has never opened a container has no
414/// sessions rather than a problem.
415pub fn scan(root: &Path) -> io::Result<Vec<Session>> {
416 let entries = match fs::read_dir(root) {
417 Ok(e) => e,
418 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
419 Err(e) => return Err(e),
420 };
421 let mut found: Vec<Session> = entries
422 .flatten()
423 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
424 .filter_map(|e| {
425 let dir = e.path();
426 read_record(&dir).ok().map(|record| Session { dir, record })
427 })
428 .collect();
429 found.sort_by(|a, b| a.dir.cmp(&b.dir));
430 Ok(found)
431}
432
433/// The session under `root` with this directory name.
434///
435/// The name is what [`scan`] shows, so it is what a person types back.
436///
437/// # Errors
438///
439/// Where there is no such session, or its record cannot be read.
440pub fn find(root: &Path, id: &str) -> io::Result<Session> {
441 // Rejected rather than joined. A name carrying a separator would reach out
442 // of the root, and the only names this answers to are ones `scan` printed.
443 if id.is_empty() || id.contains(['/', '\\']) || id == "." || id == ".." {
444 return Err(io::Error::new(
445 io::ErrorKind::NotFound,
446 format!("no session {id}"),
447 ));
448 }
449 let dir = root.join(id);
450 let record = read_record(&dir)?;
451 Ok(Session { dir, record })
452}
453
454fn seconds_since_epoch() -> u64 {
455 SystemTime::now()
456 .duration_since(UNIX_EPOCH)
457 .map_or(0, |d| d.as_secs())
458}
459
460/// Create a directory and its parents, owner-only.
461fn create_private_dir_all(at: &Path) -> io::Result<()> {
462 fs::create_dir_all(at)?;
463 private(at)
464}
465
466/// Narrow a directory to its owner.
467///
468/// Set after creation rather than through the umask, because the umask is the
469/// user's and a permissive one would leave a payload readable by every account
470/// on the machine. Windows needs nothing: `%LOCALAPPDATA%` is already scoped by
471/// an inherited ACL, and there is no mode to set.
472#[cfg(unix)]
473fn private(at: &Path) -> io::Result<()> {
474 use std::os::unix::fs::PermissionsExt as _;
475 fs::set_permissions(at, fs::Permissions::from_mode(0o700))
476}
477
478/// Nothing to narrow, for the reason the arm above gives. `Result` because that
479/// arm has one to give.
480#[allow(clippy::unnecessary_wraps)]
481#[cfg(not(unix))]
482fn private(_at: &Path) -> io::Result<()> {
483 Ok(())
484}
485
486fn write_record(dir: &Path, record: &Record) -> io::Result<()> {
487 let mut doc = toml_edit::DocumentMut::new();
488 // Lossy is wrong for a path and right for nothing, so a path that is not
489 // Unicode is refused here rather than written down wrongly and acted on
490 // later. Rare on every platform this ships to, and silently mangling a
491 // container's location is worse than saying so.
492 let container = record.container.to_str().ok_or_else(|| {
493 io::Error::new(
494 io::ErrorKind::InvalidData,
495 format!(
496 "container path is not Unicode: {}",
497 record.container.display()
498 ),
499 )
500 })?;
501 doc["container"] = toml_edit::value(container);
502 doc["payload"] = toml_edit::value(record.payload.as_str());
503 doc["started"] = toml_edit::value(i64::try_from(record.started).unwrap_or(i64::MAX));
504 doc["write_backs"] = toml_edit::value(i64::try_from(record.write_backs).unwrap_or(i64::MAX));
505 if let Some(agreed) = record.agreed {
506 doc["agreed"] = toml_edit::value(i64::from(agreed));
507 }
508 fs::write(dir.join(RECORD), doc.to_string())
509}
510
511fn read_record(dir: &Path) -> io::Result<Record> {
512 let text = fs::read_to_string(dir.join(RECORD))?;
513 let doc: toml_edit::DocumentMut = text
514 .parse()
515 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{RECORD}: {e}")))?;
516
517 let mut want = BTreeMap::new();
518 for key in ["container", "payload"] {
519 let v = doc.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
520 io::Error::new(
521 io::ErrorKind::InvalidData,
522 format!("{RECORD}: no string `{key}`"),
523 )
524 })?;
525 want.insert(key, v.to_string());
526 }
527 let number = |key: &str| -> u64 {
528 doc.get(key)
529 .and_then(toml_edit::Item::as_integer)
530 .and_then(|n| u64::try_from(n).ok())
531 .unwrap_or_default()
532 };
533
534 Ok(Record {
535 container: PathBuf::from(&want["container"]),
536 payload: want["payload"].clone(),
537 started: number("started"),
538 write_backs: number("write_backs"),
539 // Absent where an older build wrote this, which recovery reads as not
540 // known to agree rather than as agreeing.
541 agreed: doc
542 .get("agreed")
543 .and_then(toml_edit::Item::as_integer)
544 .and_then(|n| u32::try_from(n).ok()),
545 })
546}
547
548#[cfg(test)]
549mod tests {
550 use super::{create, default_root, scan, PAYLOAD_DIR, RECORD};
551 use std::fs;
552
553 /// A container on disk to point a session at. Its contents do not matter
554 /// here; what matters is that the path resolves.
555 fn a_container(at: &std::path::Path) -> std::path::PathBuf {
556 let p = at.join("report.pdf.slpc");
557 fs::write(&p, b"not a real container").unwrap();
558 p
559 }
560
561 #[test]
562 fn a_session_holds_its_record_and_a_payload_directory() {
563 let tmp = tempfile::tempdir().unwrap();
564 let root = tmp.path().join("sessions");
565 let c = a_container(tmp.path());
566
567 let s = create(&root, &c, "report.pdf").unwrap();
568 assert!(s.dir().join(RECORD).is_file());
569 assert!(s.payload_dir().is_dir());
570 assert_eq!(s.payload_dir().file_name().unwrap(), PAYLOAD_DIR);
571 assert_eq!(s.payload_path(), s.payload_dir().join("report.pdf"));
572 }
573
574 #[test]
575 fn the_payload_sits_below_the_record_rather_than_beside_it() {
576 // SPEC 2.3 permits any plain filename, `session.toml` included, so a
577 // payload beside the record could overwrite it. And concept 6.1 reads
578 // anything else in the payload directory as the target application's
579 // doing, which is only sound if the tool put one file there.
580 let tmp = tempfile::tempdir().unwrap();
581 let root = tmp.path().join("sessions");
582 let c = a_container(tmp.path());
583
584 let s = create(&root, &c, RECORD).unwrap();
585 fs::write(s.payload_path(), b"payload").unwrap();
586
587 assert!(s.dir().join(RECORD).is_file());
588 assert!(fs::read_to_string(s.dir().join(RECORD))
589 .unwrap()
590 .contains("payload ="));
591 assert_eq!(fs::read(s.payload_path()).unwrap(), b"payload");
592 }
593
594 #[test]
595 fn the_container_path_is_resolved_before_it_is_written_down() {
596 let tmp = tempfile::tempdir().unwrap();
597 let root = tmp.path().join("sessions");
598 let c = a_container(tmp.path());
599
600 // Reached through a relative fragment, which a later process in another
601 // working directory could not interpret.
602 let previous = std::env::current_dir().unwrap();
603 std::env::set_current_dir(tmp.path()).unwrap();
604 let s = create(&root, std::path::Path::new("report.pdf.slpc"), "report.pdf");
605 std::env::set_current_dir(previous).unwrap();
606
607 let s = s.unwrap();
608 assert!(s.record().container.is_absolute());
609 assert_eq!(s.record().container, fs::canonicalize(&c).unwrap());
610 }
611
612 #[test]
613 fn two_sessions_on_the_same_container_get_directories_of_their_own() {
614 // Whether that should be allowed is concept 8's question and the
615 // engine's answer. This is only that the naming does not collide.
616 let tmp = tempfile::tempdir().unwrap();
617 let root = tmp.path().join("sessions");
618 let c = a_container(tmp.path());
619
620 let a = create(&root, &c, "report.pdf").unwrap();
621 let b = create(&root, &c, "report.pdf").unwrap();
622 assert_ne!(a.dir(), b.dir());
623 }
624
625 #[test]
626 fn a_session_survives_being_written_and_read_back() {
627 let tmp = tempfile::tempdir().unwrap();
628 let root = tmp.path().join("sessions");
629 let c = a_container(tmp.path());
630
631 // A name carrying the characters that would break a hand-rolled
632 // writer. SPEC 2.3 permits both.
633 let mut s = create(&root, &c, "a \"quoted\" \\ name.pdf").unwrap();
634 s.note_write_back().unwrap();
635 s.note_write_back().unwrap();
636
637 let found = scan(&root).unwrap();
638 assert_eq!(found.len(), 1);
639 assert_eq!(found[0].record(), s.record());
640 assert_eq!(found[0].record().write_backs, 2);
641 }
642
643 #[test]
644 fn a_write_back_count_is_on_disk_before_the_call_returns() {
645 // It is only worth anything to a session that crashed, so a count kept
646 // in memory is a count lost by the event it describes.
647 let tmp = tempfile::tempdir().unwrap();
648 let root = tmp.path().join("sessions");
649 let c = a_container(tmp.path());
650
651 let mut s = create(&root, &c, "report.pdf").unwrap();
652 s.note_write_back().unwrap();
653 assert_eq!(scan(&root).unwrap()[0].record().write_backs, 1);
654 }
655
656 #[test]
657 fn scanning_a_root_that_is_not_there_finds_nothing_rather_than_failing() {
658 // A machine that has never opened a container has no sessions rather
659 // than a problem, and recovery runs on every launch.
660 let tmp = tempfile::tempdir().unwrap();
661 assert!(scan(&tmp.path().join("never-used")).unwrap().is_empty());
662 }
663
664 #[test]
665 fn a_directory_with_no_readable_record_is_skipped_rather_than_fatal() {
666 // Another process creating a session right now, or the remains of one
667 // that died between the two operations. Neither should fail a scan.
668 let tmp = tempfile::tempdir().unwrap();
669 let root = tmp.path().join("sessions");
670 let c = a_container(tmp.path());
671 let good = create(&root, &c, "report.pdf").unwrap();
672
673 fs::create_dir(root.join("half-made")).unwrap();
674 fs::write(root.join("truncated"), b"not a directory").unwrap();
675 fs::create_dir(root.join("garbled")).unwrap();
676 fs::write(root.join("garbled").join(RECORD), b"= not toml =").unwrap();
677
678 let found = scan(&root).unwrap();
679 assert_eq!(found.len(), 1);
680 assert_eq!(found[0].dir(), good.dir());
681 }
682
683 #[test]
684 fn removing_a_session_takes_the_payload_with_it() {
685 let tmp = tempfile::tempdir().unwrap();
686 let root = tmp.path().join("sessions");
687 let c = a_container(tmp.path());
688
689 let s = create(&root, &c, "report.pdf").unwrap();
690 fs::write(s.payload_path(), b"edited").unwrap();
691 let dir = s.dir().to_path_buf();
692 s.remove().unwrap();
693
694 assert!(!dir.exists());
695 assert!(scan(&root).unwrap().is_empty());
696 }
697
698 #[cfg(unix)]
699 #[test]
700 fn the_tree_is_owner_only_whatever_the_umask_says() {
701 use std::os::unix::fs::PermissionsExt as _;
702 let tmp = tempfile::tempdir().unwrap();
703 let root = tmp.path().join("sessions");
704 let c = a_container(tmp.path());
705
706 let s = create(&root, &c, "report.pdf").unwrap();
707 for d in [&root, &s.dir().to_path_buf(), &s.payload_dir()] {
708 let mode = fs::metadata(d).unwrap().permissions().mode() & 0o777;
709 assert_eq!(mode, 0o700, "{}", d.display());
710 }
711 }
712
713 #[test]
714 fn the_default_root_is_under_the_platforms_state_directory() {
715 // Not asserted against a literal path, which would only restate the
716 // code. What matters is that it is named, that it is not the system
717 // temporary directory, and that sessions are under a directory of this
718 // application's own.
719 let root = default_root().unwrap();
720 assert!(root.ends_with("slipcase-open/sessions"));
721 assert!(!root.starts_with(std::env::temp_dir()));
722 }
723
724 #[cfg(windows)]
725 #[test]
726 fn with_no_package_around_it_the_root_is_the_one_the_environment_names() {
727 // The suite runs unpackaged, so `package_store` has nothing to answer
728 // with and the fallback is what decides. This pins that: the lookup
729 // added for the packaged case must not change where an ordinary build
730 // keeps its sessions, which is where every existing install's are.
731 //
732 // What it cannot check is the packaged answer, because a test binary
733 // cannot have package identity. That half is measured against an
734 // installed package and written up in `platform_base`.
735 assert!(super::package_store().is_none());
736 let named = std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").unwrap());
737 assert_eq!(
738 default_root().unwrap(),
739 named.join("slipcase-open").join("sessions")
740 );
741 }
742
743 #[test]
744 fn removing_a_session_takes_the_payload_and_the_record_with_it() {
745 let tmp = tempfile::tempdir().unwrap();
746 let root = tmp.path().join("sessions");
747 let c = a_container(tmp.path());
748 let s = create(&root, &c, "report.pdf").unwrap();
749 fs::write(s.payload_path(), b"something").unwrap();
750 let dir = s.dir().to_path_buf();
751
752 s.remove().unwrap();
753 assert!(!dir.exists());
754 assert!(scan(&root).unwrap().is_empty());
755 }
756}