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