Skip to main content

slipcase_open/
flow.rs

1//! Concept 5's steps, joined into a session.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Open and validate, decide, extract, mark, launch, watch, write back, close.
7//! Everything security-relevant that this tool does happens on the path through
8//! [`open`], which is what concept 8 means by the engine being one body of code
9//! on three platforms.
10//!
11//! **The policy check is here and immediately before the launch.** Concept 10
12//! says enforcement lives in the launch path: a value read at startup, held
13//! across a policy push, or handed in over IPC is a bypass. So [`open`] resolves
14//! policy itself, from sources it is given rather than from an answer somebody
15//! else computed, and nothing between that decision and the launch can change
16//! what runs.
17
18use std::fmt;
19use std::path::{Path, PathBuf};
20use std::time::{Duration, Instant};
21
22use crate::outside::Outside;
23use crate::policy::{self, Decision};
24use crate::session::{self, Session};
25use crate::watch::{Change, Watch};
26use crate::{content, extract, recover, writeback};
27
28/// Why a container did not open.
29#[derive(Debug)]
30pub enum Error {
31    /// It is not a container, or not one this build can read.
32    Container(slpc::Error),
33    /// The content file is a program wearing a document's name, so it was not
34    /// opened. Concept 5.1: the one content check there is, and the only thing
35    /// it can do is refuse something policy had already allowed.
36    Misrepresented(content::Executable),
37    /// Policy will not have it opened. Carries the decision, so the refusal can
38    /// say which of the several reasons applies.
39    Refused(Decision),
40    /// Policy could not be established. Distinct from a refusal: nothing has
41    /// decided that this content file may not be opened, and the remedy is to
42    /// fix the source rather than to change the lists.
43    Policy(policy::Error),
44    /// The session directory could not be made.
45    Session(std::io::Error),
46    /// The content file did not reach the session directory.
47    Extract(extract::Error),
48    /// The desktop would not open it.
49    Launch(std::io::Error),
50    /// The content directory could not be watched. Fatal rather than
51    /// degraded: concept 6 already concedes that detection is unreliable, and a
52    /// session with no watch at all would write back only at close while
53    /// looking like one that writes back on every save.
54    Watch(notify::Error),
55}
56
57impl fmt::Display for Error {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Container(e) => write!(f, "{e}"),
61            Self::Misrepresented(what) => write!(
62                f,
63                "the content file is {}, not the document its name claims, so it was not opened",
64                what.describes()
65            ),
66            Self::Refused(d) => match d {
67                Decision::Denied { key } => write!(f, "{key} is on the deny list"),
68                Decision::NotPermitted { key } => write!(f, "{key} is not in the allowed set"),
69                Decision::NoUsableExtension => write!(
70                    f,
71                    "the content file has no usable extension, so the desktop would ask which \
72                     application to run it with"
73                ),
74                Decision::Open { .. } => write!(f, "permitted"),
75            },
76            Self::Policy(e) => write!(f, "policy could not be read: {e}"),
77            Self::Session(e) => write!(f, "the session could not be started: {e}"),
78            Self::Extract(e) => write!(f, "{e}"),
79            Self::Launch(e) => write!(f, "the content file could not be opened: {e}"),
80            Self::Watch(e) => write!(f, "the content directory could not be watched: {e}"),
81        }
82    }
83}
84
85impl std::error::Error for Error {}
86
87/// A session that is open, with its content file launched and its directory
88/// watched.
89pub struct Opened {
90    session: Session,
91    watch: Watch,
92    /// What the platform recorded about where the container came from, carried
93    /// onto the content file.
94    pub mark: slpc::provenance::Mark,
95    saw_content_change: bool,
96}
97
98/// What closing a session did.
99pub enum Closed {
100    /// Written back where asked, and the session directory removed.
101    Cleared,
102    /// The target application still has things of its own in the content
103    /// directory, so the session was handed to recovery instead of being
104    /// removed. Concept 6.2: the close is honoured, but deleting the directory
105    /// underneath a running editor sends its next save nowhere this tool will
106    /// ever look.
107    ///
108    /// The watch comes with it. Concept 8: what a resident process is good for
109    /// on this path is noticing the application's last save when it happens,
110    /// rather than leaving the question until somebody next opens a container.
111    ///
112    /// Boxed because of what the watch weighs on macOS. The first time the
113    /// gate ran on a Mac, 2026-09-07, clippy refused this enum for a variant
114    /// carrying nothing beside one carrying 256 bytes, and `size_of` put the
115    /// numbers on it there: `Lingering` 256, of which `Watch` is 144, of which
116    /// the platform's watcher is 128. A close is not a hot path, so the
117    /// indirection costs nothing anybody would notice.
118    LeftForRecovery(Box<Lingering>),
119}
120
121/// A closed session the target application has not finished with, still
122/// watched.
123///
124/// **Nothing here writes back, and that is concept 6.3 rather than an
125/// omission.** The session is closed; a save arriving now is one this tool was
126/// not watching for when the user said they were done, and it cannot tell a
127/// complete save from a half-written one. So the watch is used to know when the
128/// application has stopped, which is the moment at which asking is worth
129/// anything, and the answer comes from the person.
130pub struct Lingering {
131    session: Session,
132    watch: Watch,
133    quiet_since: Instant,
134}
135
136impl Lingering {
137    /// The session on disk.
138    #[must_use]
139    pub fn session(&self) -> &Session {
140        &self.session
141    }
142
143    /// Whether the application appears to have finished: nothing of its own
144    /// left in the content directory (concept 6.1), and nothing written there
145    /// for `quiet`.
146    ///
147    /// **Both halves, because either alone is wrong.** Siblings gone is the
148    /// signal concept 6.1 settles on, and it says the application cleaned up;
149    /// it says nothing about a save still being flushed. A quiet period alone
150    /// would fire in the middle of somebody's afternoon, between two edits.
151    ///
152    /// Takes `&mut self` because asking is also draining: a change seen here is
153    /// what resets the quiet period.
154    pub fn has_settled(&mut self, quiet: Duration) -> bool {
155        if self.watch.drain().next().is_some() {
156            self.quiet_since = Instant::now();
157        }
158        if self.quiet_since.elapsed() < quiet {
159            return false;
160        }
161        // Unreadable means the answer is not known, and the safe reading of not
162        // known is that the application is still there. A directory that has
163        // gone is the other case and settles: there is nothing left to wait
164        // for, and what remains is a recovery record naming a content file that
165        // is not on disk, which `recover` reports.
166        !crate::watch::siblings_present(
167            &self.session.content_dir(),
168            &self.session.record().content_name,
169        )
170        .unwrap_or(true)
171    }
172
173    /// Give up the watch and hand back the session, for the caller that is
174    /// about to act on it.
175    #[must_use]
176    pub fn into_session(self) -> Session {
177        self.session
178    }
179}
180
181/// Open a container: concept 5, steps 1 through 7.
182///
183/// # Errors
184///
185/// See [`Error`]. Nothing is left behind on any of them except
186/// [`Error::Extract`] carrying [`extract::Error::Unmarked`] with
187/// `content_removed` false, which says so.
188pub fn open(root: &Path, container_path: &Path, outside: &Outside<'_>) -> Result<Opened, Error> {
189    // Step 1. Opening is validating: `Container::open` applies SPEC 3 and the
190    // limits SPEC 6 asks for before it will answer any question about the file.
191    let mut container = slpc::Container::open(container_path).map_err(Error::Container)?;
192
193    // Step 2, and step 3's refusals. Resolved here rather than passed in.
194    let decision =
195        policy::decide(outside.policy, container.content_name()).map_err(Error::Policy)?;
196    if !matches!(decision, Decision::Open { .. }) {
197        return Err(Error::Refused(decision));
198    }
199
200    // Concept 5.1's content check, and it refuses.
201    //
202    // **A veto, not a control, and the distinction is what keeps 5.1's argument
203    // standing.** The extension still decides what may be opened — the
204    // allowlist above is the control, this admits nothing, and a content file
205    // that gets past here has been permitted by policy and not by inspection.
206    // All this can do is say *no* to something already permitted. 5.1's
207    // reasoning about why sniffing cannot be the control is untouched; what
208    // changed is the last line of it, which had this telling the person and
209    // standing aside.
210    //
211    // **Before the session, so nothing reaches the disk.** The bytes are read
212    // out of the container, so a refusal here means the executable was never
213    // written anywhere outside it — no session directory, no content file, no
214    // mark, and nothing for a later sweep to find. That is worth more than the
215    // warning it replaces.
216    if let Some(what) = misrepresentation(&mut container, &decision) {
217        return Err(Error::Misrepresented(what));
218    }
219
220    // Step 4.
221    let mut session =
222        session::create(root, container_path, container.content_name()).map_err(Error::Session)?;
223
224    // Steps 5 and 6. A failure here takes the session directory with it rather
225    // than leaving a half-made one for recovery to ask about.
226    let mark = match extract::extract(&mut container, &mut session) {
227        Ok(m) => m,
228        Err(e) => {
229            let content_path = session.content_path();
230            let _ = session.clone().remove();
231            // `extract` reports whether *it* managed to take the ungated
232            // content file back off disk, and then this removes the whole
233            // session directory, which usually succeeds where the single
234            // unlink did not. Left alone, the message tells somebody there is
235            // an ungated executable on disk after the file has gone. Re-asked
236            // of the filesystem, after the cleanup, so the sentence is true
237            // when it is printed.
238            return Err(Error::Extract(match e {
239                extract::Error::Unmarked { cause, .. } => extract::Error::Unmarked {
240                    cause,
241                    content_removed: !content_path.exists(),
242                },
243                other => other,
244            }));
245        }
246    };
247
248    // Step 8 before step 7: the watch is registered before the application is
249    // told the file exists, or a save that arrives quickly enough is a save
250    // nothing was listening for.
251    let watch = match Watch::on(&session.content_dir(), &session.record().content_name) {
252        Ok(w) => w,
253        Err(e) => {
254            let _ = session.clone().remove();
255            return Err(Error::Watch(e));
256        }
257    };
258
259    if let Err(e) = outside.launcher.launch(&session.content_path()) {
260        let _ = session.clone().remove();
261        return Err(Error::Launch(e));
262    }
263
264    Ok(Opened {
265        session,
266        watch,
267        mark,
268        saw_content_change: false,
269    })
270}
271
272/// What concept 5.1's check makes of the content file, read out of the
273/// container rather than off disk so the answer is available before anything
274/// is written.
275fn misrepresentation<R: std::io::Read + std::io::Seek>(
276    container: &mut slpc::Container<R>,
277    decision: &Decision,
278) -> Option<content::Executable> {
279    let key = match decision {
280        Decision::Open { key } => Some(key.as_str()),
281        _ => None,
282    };
283    let mut head = [0u8; content::HEAD];
284    let mut piece = container.content().ok()?;
285    let mut at = 0;
286    while at < head.len() {
287        match std::io::Read::read(&mut piece, &mut head[at..]) {
288            Ok(0) | Err(_) => break,
289            Ok(n) => at += n,
290        }
291    }
292    content::misrepresents(&head[..at], key)
293}
294
295impl Opened {
296    /// The session on disk.
297    #[must_use]
298    pub fn session(&self) -> &Session {
299        &self.session
300    }
301
302    /// Where the content file was put.
303    #[must_use]
304    pub fn content_path(&self) -> PathBuf {
305        self.session.content_path()
306    }
307
308    /// Whether the content file has been seen to change since the session
309    /// opened.
310    #[must_use]
311    pub fn saw_a_change(&self) -> bool {
312        self.saw_content_change
313    }
314
315    /// Whether the target application has anything of its own in the content
316    /// directory (concept 6.1).
317    ///
318    /// # Errors
319    ///
320    /// Where the content directory cannot be read.
321    pub fn application_is_working(&self) -> std::io::Result<bool> {
322        crate::watch::siblings_present(
323            &self.session.content_dir(),
324            &self.session.record().content_name,
325        )
326    }
327
328    /// Take whatever the watch has to say, and write back once if the content
329    /// file was among it.
330    ///
331    /// Once, rather than once per event. A single save arrives as several
332    /// events — a temporary sibling, a rename, a metadata touch — and repacking
333    /// per event would rebuild the container three times to the same end.
334    ///
335    /// # Errors
336    ///
337    /// Where the write-back failed. The session stays open: concept 6.2 puts
338    /// the close at the user's hand, and a failed save is a reason to tell them
339    /// rather than to give up on the container.
340    pub fn pump(&mut self) -> Result<bool, writeback::Error> {
341        self.pump_including(None)
342    }
343
344    /// [`pump`](Self::pump), counting a change already taken off the channel.
345    ///
346    /// **A change that has been received is a change that has happened.**
347    /// `wait_and_pump` blocks by taking one change off the channel, so passing
348    /// it in here is what stops that one being dropped on the floor. No save is
349    /// known to have been lost to the earlier version — every save measured
350    /// emits more than one event, and the next drain collects the rest — but it
351    /// relied on that being true of every application on three platforms, which
352    /// is not a thing this code is in a position to know.
353    fn pump_including(&mut self, first: Option<Change>) -> Result<bool, writeback::Error> {
354        let mut content_changed = first == Some(Change::Content);
355        for change in self.watch.drain() {
356            if change == Change::Content {
357                content_changed = true;
358            }
359        }
360        if !content_changed {
361            return Ok(false);
362        }
363        self.saw_content_change = true;
364        self.save_if_changed()
365    }
366
367    /// Write the content file back, unless it already matches what the
368    /// container holds.
369    ///
370    /// **Asked of the bytes rather than of the events.** One save arrives as
371    /// several events — a temporary sibling, a rename, a metadata touch — and
372    /// they do not reliably land in one drain, so counting events makes the
373    /// number of repacks a function of how busy the machine is. A quiet period
374    /// before repacking would trade that for latency on every save and still
375    /// only make the guess better. `recover` answers the real question by
376    /// comparing against the CRC-32 the container already records (concept
377    /// 6.3), so a redundant event costs one comparison instead of one rebuild.
378    ///
379    /// **Only the two quiet states are silent.** An earlier version returned
380    /// *nothing to do* for every state that was not `Edited`, which meant a
381    /// container deleted or replaced underneath a live session stopped it
382    /// saving without saying anything — the user edits, nothing is written, and
383    /// no error appears. Those states go to the write-back to be refused and
384    /// reported, which is where the refusal belongs anyway.
385    ///
386    /// # Errors
387    ///
388    /// Where the write-back failed, or cannot be attempted at all.
389    pub fn save_if_changed(&mut self) -> Result<bool, writeback::Error> {
390        match recover::state(&self.session) {
391            // Nothing to write, and nothing wrong.
392            recover::State::Unchanged | recover::State::NothingExtracted => Ok(false),
393            // `Edited`, and every state that means this session can no longer
394            // reach its container. `write_back` refuses the ones it must and
395            // names the reason.
396            _ => {
397                writeback::write_back(&mut self.session)?;
398                Ok(true)
399            }
400        }
401    }
402
403    /// Wait up to `within` for something to happen, then [`pump`](Self::pump).
404    ///
405    /// # Errors
406    ///
407    /// As [`pump`](Self::pump).
408    pub fn wait_and_pump(&mut self, within: Duration) -> Result<bool, writeback::Error> {
409        let first = self.watch.next_change(within);
410        self.pump_including(first)
411    }
412
413    /// Close the session: catch up on the watch, then clean up.
414    ///
415    /// Concept 6.2's question — *write it back anyway?* — is the caller's, and
416    /// so is the answer: it asks, and calls
417    /// [`save_if_changed`](Self::save_if_changed) if the answer is yes. This
418    /// used to take a `bool` and repack unconditionally on it, which rebuilt
419    /// the container even when the content file matched it byte for byte, and
420    /// rebuilt it twice when the final pump had just done so.
421    ///
422    /// # Errors
423    ///
424    /// Where the final catch-up write-back failed, in which case nothing is
425    /// removed and the session stays recoverable.
426    pub fn close(mut self) -> Result<Closed, writeback::Error> {
427        // Anything the watch has not been asked about yet. A save arriving
428        // between the last pump and the close is a save.
429        self.pump()?;
430
431        // Concept 6.2. The close is honoured either way; what changes is
432        // whether the directory goes now or is handed to recovery, so that an
433        // editor still holding the content file has somewhere for its next
434        // save to land and the next launch asks about it.
435        if self.application_is_working().unwrap_or(true) {
436            return Ok(Closed::LeftForRecovery(Box::new(Lingering {
437                session: self.session,
438                watch: self.watch,
439                quiet_since: Instant::now(),
440            })));
441        }
442        // **The watch goes before the directory does, and the order is load
443        // bearing.** `Watch::drop` waits for the platform to finish stopping
444        // it, so by the line below there is no watcher left to collide with the
445        // removal. Removing first, which is what this used to do, is what a
446        // 300-second hang was caught doing; `docs/windows-save-test-hang.md`
447        // has the stack.
448        drop(self.watch);
449
450        // A failure to remove leaves a session recovery will pick up, which is
451        // the same outcome by another road and not worth a second error type.
452        let _ = self.session.remove();
453        Ok(Closed::Cleared)
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::{open, Closed, Error};
460    use crate::outside::Outside;
461    use crate::platform::testing::Recording;
462    use crate::policy::{Layer, Origin, Source};
463    use crate::present::testing::Silent;
464    use crate::writeback;
465    use std::fs;
466    use std::path::{Path, PathBuf};
467    use std::time::Duration;
468
469    /// Says nothing at every layer, so the shipped set answers.
470    struct Default_;
471    impl Source for Default_ {
472        fn layer(&self, _o: Origin) -> crate::policy::Read {
473            Ok(None)
474        }
475    }
476
477    /// Denies everything, for the refusal arms.
478    struct DenyAll;
479    impl Source for DenyAll {
480        fn layer(&self, o: Origin) -> crate::policy::Read {
481            Ok((o == Origin::MachinePolicy).then(|| Layer {
482                allowed: Some(Vec::new()),
483                ..Layer::default()
484            }))
485        }
486    }
487
488    fn container(at: &Path, name: &str, content_bytes: &[u8]) -> PathBuf {
489        let doc: slpc::toml_edit::DocumentMut =
490            format!("slipcase_version = \"1.1\"\n\n[content]\nfile = \"{name}\"\n")
491                .parse()
492                .unwrap();
493        let path = at.join(format!("{name}.slpc"));
494        slpc::pack_reader(name, content_bytes, doc, fs::File::create(&path).unwrap()).unwrap();
495        path
496    }
497
498    #[test]
499    fn opening_extracts_launches_and_watches() {
500        let tmp = tempfile::tempdir().unwrap();
501        let root = tmp.path().join("sessions");
502        let c = container(tmp.path(), "report.pdf", b"%PDF first");
503        let launcher = Recording::default();
504
505        let o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
506        assert_eq!(launcher.launched(), [o.content_path()]);
507        assert_eq!(fs::read(o.content_path()).unwrap(), b"%PDF first");
508        assert!(!o.saw_a_change());
509    }
510
511    #[test]
512    fn a_save_reaches_the_container_without_anybody_closing_the_session() {
513        let tmp = tempfile::tempdir().unwrap();
514        let root = tmp.path().join("sessions");
515        let c = container(tmp.path(), "report.pdf", b"first");
516        let launcher = Recording::default();
517
518        let mut o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
519
520        // The way a serious editor saves: a temporary sibling renamed over the
521        // target, which is the case a watch on the file would miss.
522        let scratch = o.content_path().with_extension("pdf.tmp");
523        fs::write(&scratch, b"edited").unwrap();
524        fs::rename(&scratch, o.content_path()).unwrap();
525
526        let deadline = std::time::Instant::now() + Duration::from_secs(10);
527        while std::time::Instant::now() < deadline && !o.saw_a_change() {
528            o.wait_and_pump(Duration::from_millis(250)).unwrap();
529        }
530        assert!(o.saw_a_change(), "the save never reached the session");
531
532        let mut back = slpc::Container::open(&c).unwrap();
533        let mut got = Vec::new();
534        std::io::copy(&mut back.content().unwrap(), &mut got).unwrap();
535        assert_eq!(got, b"edited");
536    }
537
538    #[test]
539    fn a_save_that_emits_one_event_still_reaches_the_container() {
540        // Concept 6 is written about editors that save atomically, and the
541        // tests followed it there. This is the other shape: a plain write in
542        // place, which emits fewer events. It passes either side of the
543        // `pump_including` change rather than pinning it — what it pins is that
544        // the simple save works at all, which nothing else asserted.
545        let tmp = tempfile::tempdir().unwrap();
546        let root = tmp.path().join("sessions");
547        let c = container(tmp.path(), "report.pdf", b"first");
548        let mut o = open(
549            &root,
550            &c,
551            &Outside::new(&Default_, &Recording::default(), &Silent),
552        )
553        .unwrap();
554
555        fs::write(o.content_path(), b"edited in place").unwrap();
556
557        let deadline = std::time::Instant::now() + Duration::from_secs(10);
558        while std::time::Instant::now() < deadline && !o.saw_a_change() {
559            o.wait_and_pump(Duration::from_millis(250)).unwrap();
560        }
561        assert!(o.saw_a_change(), "a single-event save was never noticed");
562
563        let mut back = slpc::Container::open(&c).unwrap();
564        let mut got = Vec::new();
565        std::io::copy(&mut back.content().unwrap(), &mut got).unwrap();
566        assert_eq!(got, b"edited in place");
567    }
568
569    #[test]
570    fn one_save_is_one_write_back() {
571        // A repack costs a full rebuild of the container, so the number of
572        // them a session performs should follow the edits and not the event
573        // traffic. Counting events cannot give that: one save arrives as
574        // several, they do not reliably land in one drain, and this test was
575        // flaky under a loaded suite for exactly that reason before `pump`
576        // compared the bytes instead.
577        //
578        // Counted rather than inspected, because every redundant repack writes
579        // the same bytes — a test asserting the container's contents passes
580        // whatever the count is.
581        let tmp = tempfile::tempdir().unwrap();
582        let root = tmp.path().join("sessions");
583        let c = container(tmp.path(), "report.pdf", b"first");
584        let launcher = Recording::default();
585
586        let mut o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
587
588        let scratch = o.content_path().with_extension("pdf.tmp");
589        fs::write(&scratch, b"edited").unwrap();
590        fs::rename(&scratch, o.content_path()).unwrap();
591
592        // Pump well past the point where the save has landed, so every event
593        // it produced has arrived and been acted on.
594        let deadline = std::time::Instant::now() + Duration::from_secs(3);
595        while std::time::Instant::now() < deadline {
596            o.wait_and_pump(Duration::from_millis(100)).unwrap();
597        }
598
599        assert!(o.saw_a_change(), "the save never reached the session");
600        assert_eq!(
601            o.session().record().write_backs,
602            1,
603            "one save produced more than one write-back"
604        );
605    }
606
607    #[test]
608    fn policy_refuses_before_a_session_directory_exists() {
609        // Concept 10 puts enforcement in the launch path, and a refusal that
610        // had already written the content file somewhere would be a refusal in
611        // name.
612        let tmp = tempfile::tempdir().unwrap();
613        let root = tmp.path().join("sessions");
614        let c = container(tmp.path(), "report.pdf", b"first");
615        let launcher = Recording::default();
616
617        assert!(matches!(
618            open(&root, &c, &Outside::new(&DenyAll, &launcher, &Silent)),
619            Err(Error::Refused(_))
620        ));
621        assert!(launcher.launched().is_empty());
622        assert!(crate::session::scan(&root).unwrap().is_empty());
623    }
624
625    #[test]
626    fn a_content_file_with_no_usable_extension_is_refused() {
627        let tmp = tempfile::tempdir().unwrap();
628        let root = tmp.path().join("sessions");
629        let c = container(tmp.path(), "README", b"hello");
630        let launcher = Recording::default();
631
632        match open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)) {
633            Err(e) => assert!(e.to_string().contains("no usable extension"), "{e}"),
634            Ok(_) => panic!("a content file with no usable extension was opened"),
635        }
636        assert!(crate::session::scan(&root).unwrap().is_empty());
637    }
638
639    #[test]
640    fn an_executable_wearing_a_documents_name_is_refused() {
641        // Concept 5.1's check, as a veto. It admits nothing — policy had
642        // already allowed `.pdf` — and all it does here is say no to something
643        // policy allowed.
644        let tmp = tempfile::tempdir().unwrap();
645        let root = tmp.path().join("sessions");
646        let c = container(tmp.path(), "invoice.pdf", b"MZ\x90\x00 not a pdf");
647        let launcher = Recording::default();
648
649        match open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)) {
650            Err(Error::Misrepresented(what)) => {
651                assert_eq!(what, crate::content::Executable::Pe);
652            }
653            Err(e) => panic!("refused for the wrong reason: {e}"),
654            Ok(_) => panic!("a program wearing a document's name was opened"),
655        }
656        assert!(
657            launcher.launched().is_empty(),
658            "nothing was handed to the desktop"
659        );
660        // The refusal is before the session, so the bytes never left the
661        // container: no session directory, no content file on disk, and
662        // nothing for a later sweep to find.
663        assert!(
664            !root.exists() || crate::session::scan(&root).unwrap().is_empty(),
665            "the executable reached the disk"
666        );
667    }
668
669    #[test]
670    fn a_program_under_its_own_name_is_left_to_policy() {
671        // The other half of *veto, not control*: this check never admits
672        // anything and never fires on a content file that is what it says.
673        // What happens to a `.exe` is the allowlist's business, and here
674        // nothing stands in its way.
675        let tmp = tempfile::tempdir().unwrap();
676        let root = tmp.path().join("sessions");
677        let c = container(tmp.path(), "setup.exe", b"MZ\x90\x00 an installer");
678        let launcher = Recording::default();
679
680        let opened = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent));
681        assert!(
682            !matches!(opened, Err(Error::Misrepresented(_))),
683            "the content check refused a content file that is what its name says"
684        );
685    }
686
687    #[test]
688    fn a_desktop_that_will_not_open_it_leaves_nothing_behind() {
689        let tmp = tempfile::tempdir().unwrap();
690        let root = tmp.path().join("sessions");
691        let c = container(tmp.path(), "report.pdf", b"first");
692
693        assert!(matches!(
694            open(
695                &root,
696                &c,
697                &Outside::new(&Default_, &Recording::refusing(), &Silent)
698            ),
699            Err(Error::Launch(_))
700        ));
701        assert!(crate::session::scan(&root).unwrap().is_empty());
702    }
703
704    #[test]
705    fn closing_without_a_change_can_still_write_back() {
706        // The only available answer to Save As: no event fires when somebody
707        // saves elsewhere, so a session that saw nothing may still have an edit
708        // that belongs in the container.
709        let tmp = tempfile::tempdir().unwrap();
710        let root = tmp.path().join("sessions");
711        let c = container(tmp.path(), "report.pdf", b"first");
712        let launcher = Recording::default();
713
714        let mut o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
715        fs::write(o.content_path(), b"edited quietly").unwrap();
716        // Deliberately not pumped: this is the path where nothing was seen.
717        assert!(o.save_if_changed().unwrap());
718        assert!(matches!(o.close().unwrap(), Closed::Cleared));
719
720        let mut back = slpc::Container::open(&c).unwrap();
721        let mut got = Vec::new();
722        std::io::copy(&mut back.content().unwrap(), &mut got).unwrap();
723        assert_eq!(got, b"edited quietly");
724        assert!(crate::session::scan(&root).unwrap().is_empty());
725    }
726
727    #[test]
728    fn closing_while_the_application_is_working_hands_over_to_recovery() {
729        // Concept 6.2: the close is honoured, but removing the directory under
730        // a running editor sends its next save nowhere this tool will look.
731        let tmp = tempfile::tempdir().unwrap();
732        let root = tmp.path().join("sessions");
733        let c = container(tmp.path(), "report.pdf", b"first");
734        let launcher = Recording::default();
735
736        // No edit here, deliberately. `close` pumps before it decides, so a
737        // save made in this test may or may not have reached the container by
738        // the time the state is read — asserting on that state made this fail
739        // about one run in six. What the handover rule guarantees is that the
740        // directory survives, and that is what is checked.
741        let o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
742        let content_path = o.content_path();
743        fs::write(content_path.with_file_name("~$report.pdf"), b"").unwrap();
744
745        assert!(matches!(o.close().unwrap(), Closed::LeftForRecovery(_)));
746
747        let left = crate::session::scan(&root).unwrap();
748        assert_eq!(left.len(), 1);
749        // Still there for the editor's next save to land in, which is the whole
750        // point of not deleting it.
751        assert!(content_path.is_file());
752    }
753
754    #[test]
755    fn a_container_deleted_under_a_live_session_is_reported_rather_than_ignored() {
756        // Found in review, and it was a regression: once `pump` compared bytes,
757        // every state that was not `Edited` returned *nothing to do*, so a
758        // container removed underneath a session stopped it saving and said
759        // nothing at all. The person keeps editing and no error ever appears.
760        let tmp = tempfile::tempdir().unwrap();
761        let root = tmp.path().join("sessions");
762        let c = container(tmp.path(), "report.pdf", b"first");
763        let mut o = open(
764            &root,
765            &c,
766            &Outside::new(&Default_, &Recording::default(), &Silent),
767        )
768        .unwrap();
769
770        fs::write(o.content_path(), b"edited").unwrap();
771        fs::remove_file(&c).unwrap();
772
773        assert!(matches!(
774            o.save_if_changed(),
775            Err(writeback::Error::Container(_))
776        ));
777    }
778
779    #[test]
780    fn a_different_container_at_the_recorded_path_refuses_the_write_back() {
781        // The guard belongs on the acting side and not only in `recover`:
782        // repacking here would rename the content file of a container this
783        // session was never opened against.
784        let tmp = tempfile::tempdir().unwrap();
785        let root = tmp.path().join("sessions");
786        let c = container(tmp.path(), "report.pdf", b"first");
787        let mut o = open(
788            &root,
789            &c,
790            &Outside::new(&Default_, &Recording::default(), &Silent),
791        )
792        .unwrap();
793
794        fs::write(o.content_path(), b"edited").unwrap();
795        let other = container(tmp.path(), "plan.dwg", b"unrelated");
796        fs::rename(&other, &c).unwrap();
797
798        match o.save_if_changed() {
799            Err(writeback::Error::ContainerChanged { recorded, found }) => {
800                assert_eq!(recorded, "report.pdf");
801                assert_eq!(found, "plan.dwg");
802            }
803            other => panic!("{other:?}"),
804        }
805        // Untouched: still the other container, still its own content name.
806        assert_eq!(
807            slpc::Container::open(&c).unwrap().content_name(),
808            "plan.dwg"
809        );
810    }
811
812    #[test]
813    fn saying_yes_to_an_unchanged_content_file_rebuilds_nothing() {
814        // `close` used to take the answer as a `bool` and repack on it without
815        // asking whether anything had changed. That signature is gone, so this
816        // cannot be made to fail by reverting the fix the way the two above
817        // can; it pins the behaviour rather than the defect. What it is worth
818        // is that rewriting the only copy of a container is not a free
819        // operation, and answering *yes* to a question about a content file
820        // nobody edited should cost nothing.
821        let tmp = tempfile::tempdir().unwrap();
822        let root = tmp.path().join("sessions");
823        let c = container(tmp.path(), "report.pdf", b"first");
824        let mut o = open(
825            &root,
826            &c,
827            &Outside::new(&Default_, &Recording::default(), &Silent),
828        )
829        .unwrap();
830
831        assert!(!o.save_if_changed().unwrap());
832        assert_eq!(o.session().record().write_backs, 0);
833    }
834
835    #[test]
836    fn an_edit_is_written_back_once_however_many_times_it_is_asked_for() {
837        let tmp = tempfile::tempdir().unwrap();
838        let root = tmp.path().join("sessions");
839        let c = container(tmp.path(), "report.pdf", b"first");
840        let mut o = open(
841            &root,
842            &c,
843            &Outside::new(&Default_, &Recording::default(), &Silent),
844        )
845        .unwrap();
846
847        fs::write(o.content_path(), b"edited").unwrap();
848        assert!(o.save_if_changed().unwrap());
849        assert!(!o.save_if_changed().unwrap());
850        assert!(!o.save_if_changed().unwrap());
851        assert_eq!(o.session().record().write_backs, 1);
852    }
853
854    #[test]
855    fn a_clean_close_leaves_nothing_for_recovery_to_ask_about() {
856        let tmp = tempfile::tempdir().unwrap();
857        let root = tmp.path().join("sessions");
858        let c = container(tmp.path(), "report.pdf", b"first");
859        let launcher = Recording::default();
860
861        let o = open(&root, &c, &Outside::new(&Default_, &launcher, &Silent)).unwrap();
862        assert!(matches!(o.close().unwrap(), Closed::Cleared));
863        assert!(crate::session::scan(&root).unwrap().is_empty());
864    }
865
866    /// How wide and how long the two stress diagnostics below run.
867    ///
868    /// Threads, because the first single-threaded attempt at these was too
869    /// gentle to wedge anything: 40,000 rounds of the exact shape that wedges
870    /// in the suite produced nothing, while the suite itself wedges at roughly
871    /// one run in twenty-seven. The suite runs its tests across threads and
872    /// this did not, which makes concurrent watchers the first difference to
873    /// put back.
874    fn stress_shape() -> (usize, usize) {
875        let total: usize = std::env::var("SLPC_CLOSE_ROUNDS")
876            .ok()
877            .and_then(|v| v.parse().ok())
878            .unwrap_or(500);
879        let threads: usize = std::env::var("SLPC_CLOSE_THREADS")
880            .ok()
881            .and_then(|v| v.parse().ok())
882            .unwrap_or_else(|| {
883                std::thread::available_parallelism().map_or(8, std::num::NonZero::get)
884            });
885        (total.div_ceil(threads), threads)
886    }
887
888    /// **A diagnostic, not part of the gate**, which is why it is `ignore`d.
889    ///
890    /// `docs/windows-save-test-hang.md` caught a wedge in `TempDir::drop`:
891    /// `remove_dir_all` on a watched directory against the watcher re-arming
892    /// `ReadDirectoryChangesW` on it. `close` does that collision by
893    /// construction rather than by luck — `session.remove()` removes the
894    /// watched directory while `self.watch` is still alive, and the watch is
895    /// dropped only afterwards. This loops the shipping close path so the
896    /// question can be answered by measurement instead of by reading.
897    ///
898    /// Run it explicitly, under an external timeout, and read the stack of
899    /// anything that stops:
900    ///
901    /// ```text
902    /// cargo test --lib -- --ignored --exact flow::tests::close_alone_under_repetition
903    /// ```
904    ///
905    /// A wedge whose stack shows `remove_dir_all` under `session::remove` is
906    /// the product hanging on close. One under `tempfile` is the teardown
907    /// already written up, and says nothing new.
908    #[test]
909    #[ignore = "diagnostic; run explicitly under an external timeout"]
910    fn close_alone_under_repetition() {
911        let (rounds, threads) = stress_shape();
912        std::thread::scope(|s| {
913            for t in 0..threads {
914                s.spawn(move || {
915                    for i in 0..rounds {
916                        let tmp = tempfile::tempdir().unwrap();
917                        let root = tmp.path().join("sessions");
918                        let c = container(tmp.path(), "report.pdf", b"first");
919                        let o = open(
920                            &root,
921                            &c,
922                            &Outside::new(&Default_, &Recording::default(), &Silent),
923                        )
924                        .unwrap();
925
926                        // A save immediately before the close, so the watcher
927                        // has a completed notification in flight when the
928                        // directory goes. That is the state the captured stack
929                        // was in.
930                        fs::write(o.content_path(), b"edited").unwrap();
931
932                        assert!(matches!(o.close().unwrap(), Closed::Cleared));
933
934                        // Sparse on purpose: instrumentation is what hid this.
935                        if t == 0 && i % 50 == 0 {
936                            eprintln!("round {i}");
937                        }
938                    }
939                });
940            }
941        });
942    }
943
944    /// **The positive control for [`close_alone_under_repetition`].** Same
945    /// loop, minus the close: `o` and `tmp` both go at the end of the scope,
946    /// so the watch is stopped while `remove_dir_all` walks the directory it
947    /// was watching. That is the shape of the stack in
948    /// `docs/windows-save-test-hang.md`, and this is the run that says whether
949    /// the harness can catch it at all.
950    ///
951    /// Without this, "no wedge in N closes" is not evidence about `close`; it
952    /// is only evidence that the loop is too gentle to wedge anything — which
953    /// is exactly what the single-threaded version of both turned out to be.
954    #[test]
955    #[ignore = "diagnostic; run explicitly under an external timeout"]
956    fn teardown_alone_under_repetition() {
957        let (rounds, threads) = stress_shape();
958        std::thread::scope(|s| {
959            for t in 0..threads {
960                s.spawn(move || {
961                    for i in 0..rounds {
962                        let tmp = tempfile::tempdir().unwrap();
963                        let root = tmp.path().join("sessions");
964                        let c = container(tmp.path(), "report.pdf", b"first");
965                        let mut o = open(
966                            &root,
967                            &c,
968                            &Outside::new(&Default_, &Recording::default(), &Silent),
969                        )
970                        .unwrap();
971
972                        // Mirrors the test that wedged: a save, then the save
973                        // that finds nothing to do, then the scope ends. No
974                        // close.
975                        fs::write(o.content_path(), b"edited").unwrap();
976                        assert!(o.save_if_changed().unwrap());
977                        assert!(!o.save_if_changed().unwrap());
978
979                        if t == 0 && i % 50 == 0 {
980                            eprintln!("round {i}");
981                        }
982                        // `o` drops here, then `tmp`: stop_watch against
983                        // remove_dir_all.
984                    }
985                });
986            }
987        });
988    }
989
990    /// **Does the collision cross directories?** The close capture could not
991    /// say: eight workers and several watchers were alive, so the watch that
992    /// collided with the close might have been the closing session's own or a
993    /// neighbour's. This separates them.
994    ///
995    /// One worker runs the shipping close path on its own directory. One
996    /// neighbour churns watches on a directory it **never removes** — so the
997    /// neighbour can never wedge on a teardown of its own, and every
998    /// `stop_watch` in flight belongs to it rather than to the worker, whose
999    /// own watch is not stopping during its `remove` (`close` drops it after).
1000    ///
1001    /// So a wedge here is a `remove_dir_all` on one directory against a
1002    /// `stop_watch` on a *different* one, and the fix has to be wider than
1003    /// ordering a session's own teardown. Its control is
1004    /// [`close_alone_under_repetition`] run with `SLPC_CLOSE_THREADS=1`, which
1005    /// is the same worker with no neighbour at all.
1006    #[test]
1007    #[ignore = "diagnostic; run explicitly under an external timeout"]
1008    fn close_with_a_neighbouring_watch() {
1009        let rounds: usize = std::env::var("SLPC_CLOSE_ROUNDS")
1010            .ok()
1011            .and_then(|v| v.parse().ok())
1012            .unwrap_or(400);
1013
1014        let stop = std::sync::atomic::AtomicBool::new(false);
1015        std::thread::scope(|s| {
1016            s.spawn(|| {
1017                let ntmp = tempfile::tempdir().unwrap();
1018                let content_path = ntmp.path().join("neighbour.pdf");
1019                fs::write(&content_path, b"x").unwrap();
1020                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1021                    let w = crate::watch::Watch::on(ntmp.path(), "neighbour.pdf").unwrap();
1022                    fs::write(&content_path, b"y").unwrap();
1023                    drop(w);
1024                }
1025            });
1026
1027            for i in 0..rounds {
1028                let tmp = tempfile::tempdir().unwrap();
1029                let root = tmp.path().join("sessions");
1030                let c = container(tmp.path(), "report.pdf", b"first");
1031                let o = open(
1032                    &root,
1033                    &c,
1034                    &Outside::new(&Default_, &Recording::default(), &Silent),
1035                )
1036                .unwrap();
1037                fs::write(o.content_path(), b"edited").unwrap();
1038                assert!(matches!(o.close().unwrap(), Closed::Cleared));
1039                if i % 50 == 0 {
1040                    eprintln!("round {i}");
1041                }
1042            }
1043            stop.store(true, std::sync::atomic::Ordering::Relaxed);
1044        });
1045    }
1046
1047    /// **A `close` diagnostic that cannot wedge in its own teardown**, so what
1048    /// it counts is `close` and not the harness.
1049    ///
1050    /// [`close_alone_under_repetition`] drops a `TempDir` every round, and
1051    /// three of its four captured wedges were in that drop rather than in
1052    /// `close` — which is why its numbers say `close` can wedge but not how
1053    /// often. Here `TempDir::keep` hands the directory over undeleted, so the
1054    /// only `remove_dir_all` this process performs is the one inside
1055    /// `Session::remove` under `Opened::close`. A wedge is the product path by
1056    /// construction rather than by attribution.
1057    ///
1058    /// It leaves `slpc-leak-*` directories under the system temp directory on
1059    /// purpose. Removing them is the loop's job, between runs, when the
1060    /// process is gone and no watch is alive to collide with the removal.
1061    #[test]
1062    #[ignore = "diagnostic; run explicitly under an external timeout; leaks temp dirs by design"]
1063    fn close_alone_leaving_the_directory_behind() {
1064        let (rounds, threads) = stress_shape();
1065        std::thread::scope(|s| {
1066            for t in 0..threads {
1067                s.spawn(move || {
1068                    for i in 0..rounds {
1069                        let dir = tempfile::Builder::new()
1070                            .prefix("slpc-leak-")
1071                            .tempdir()
1072                            .unwrap()
1073                            .keep();
1074                        let root = dir.join("sessions");
1075                        let c = container(&dir, "report.pdf", b"first");
1076                        let o = open(
1077                            &root,
1078                            &c,
1079                            &Outside::new(&Default_, &Recording::default(), &Silent),
1080                        )
1081                        .unwrap();
1082                        fs::write(o.content_path(), b"edited").unwrap();
1083
1084                        // The only removal in the process.
1085                        assert!(matches!(o.close().unwrap(), Closed::Cleared));
1086
1087                        if t == 0 && i % 50 == 0 {
1088                            eprintln!("round {i}");
1089                        }
1090                    }
1091                });
1092            }
1093        });
1094    }
1095
1096    /// **The product's actual shape**, which none of the diagnostics above
1097    /// have. `Resident` serves every request on one thread: the accepting
1098    /// thread only forwards streams down a channel, and `handle`, `turn` and
1099    /// every `close` run in the single main loop. So the product never closes
1100    /// two sessions at once, and the eight concurrent closers the other
1101    /// diagnostics use correspond to nothing it does.
1102    ///
1103    /// What it *does* do is `stand_down`: several sessions open together, each
1104    /// holding a live watch, then closed one after another on that one thread.
1105    /// The exposure there was never two removals racing — it was one close's
1106    /// watch still stopping when the next close's removal began, because the
1107    /// stop used to be asynchronous. `Watch::drop` waiting is meant to close
1108    /// exactly that window, and this is the test of whether it does.
1109    ///
1110    /// `SLPC_SESSIONS` is how many are open at once. Directories are leaked
1111    /// with `TempDir::keep`, so the only removal in the process is `close`'s.
1112    #[test]
1113    #[ignore = "diagnostic; run explicitly under an external timeout; leaks temp dirs by design"]
1114    fn a_stand_down_shaped_close() {
1115        let rounds: usize = std::env::var("SLPC_CLOSE_ROUNDS")
1116            .ok()
1117            .and_then(|v| v.parse().ok())
1118            .unwrap_or(400);
1119        let at_once: usize = std::env::var("SLPC_SESSIONS")
1120            .ok()
1121            .and_then(|v| v.parse().ok())
1122            .unwrap_or(8);
1123
1124        for i in 0..rounds {
1125            // Several sessions live at the same time, as an instance holds
1126            // them, each with its own watch on its own directory.
1127            let mut open_now = Vec::with_capacity(at_once);
1128            for _ in 0..at_once {
1129                let dir = tempfile::Builder::new()
1130                    .prefix("slpc-leak-")
1131                    .tempdir()
1132                    .unwrap()
1133                    .keep();
1134                let root = dir.join("sessions");
1135                let c = container(&dir, "report.pdf", b"first");
1136                let o = open(
1137                    &root,
1138                    &c,
1139                    &Outside::new(&Default_, &Recording::default(), &Silent),
1140                )
1141                .unwrap();
1142                fs::write(o.content_path(), b"edited").unwrap();
1143                open_now.push(o);
1144            }
1145
1146            // `stand_down`: one thread, one after another, no concurrency.
1147            for o in open_now {
1148                assert!(matches!(o.close().unwrap(), Closed::Cleared));
1149            }
1150
1151            if i % 25 == 0 {
1152                eprintln!("round {i}");
1153            }
1154        }
1155    }
1156}