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