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}