Skip to main content

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}