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