Skip to main content

slipcase_open/
platform.rs

1//! The parts that differ by platform, behind one small trait.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 12 structures the differences as a trait with three implementations
7//! rather than treating cross-platform as a yes-or-no decision. This is the
8//! launch half of it; the policy sources and the presentation join it in
9//! PLAN.md Phases 3 and 4.
10//!
11//! **Launching does not wait, and cannot.** Concept 6 starts from the
12//! observation that handing a document to the desktop frequently returns at
13//! once, because the file goes to an already-running instance of the target
14//! application and there is no child process to wait on. Roughly half of real
15//! applications behave that way, so a launch that waited would be right about
16//! half the time and silently wrong about the rest. The session model exists
17//! because of this, and the launcher's job stops at handing the file over.
18
19use std::io;
20use std::path::Path;
21
22/// Handing a payload to whatever the desktop says opens it.
23pub trait Launcher {
24    /// Open `payload` with the platform's own handler.
25    ///
26    /// # Errors
27    ///
28    /// Where the platform's launcher cannot be run, or refuses.
29    fn launch(&self, payload: &Path) -> io::Result<()>;
30}
31
32/// This machine.
33pub struct Host;
34
35/// `xdg-open`, which every desktop on this platform provides or is expected to.
36///
37/// There is no trust-zone marking to apply here and concept 12 says so out
38/// loud: Linux keeps provenance as a note rather than as a gate, so
39/// `slpc::provenance` records where a payload came from and nothing consults it.
40/// That is the platform's shape rather than an omission in this code, and the
41/// administrator documentation states it rather than leaving it to be
42/// discovered.
43#[cfg(target_os = "linux")]
44impl Launcher for Host {
45    fn launch(&self, payload: &Path) -> io::Result<()> {
46        spawn_detached("xdg-open", payload)
47    }
48}
49
50/// `open`, which consults `com.apple.quarantine` on the way, so the mark
51/// carried onto the payload at extraction is what raises the warning.
52#[cfg(target_os = "macos")]
53impl Launcher for Host {
54    fn launch(&self, payload: &Path) -> io::Result<()> {
55        spawn_detached("open", payload)
56    }
57}
58
59/// `ShellExecuteEx` with the default verb, which is what a double-click runs.
60///
61/// The zone check is the point of this arm, and it is switched on by *not*
62/// switching it off — see [`shell`], which holds the measurements.
63#[cfg(target_os = "windows")]
64impl Launcher for Host {
65    fn launch(&self, payload: &Path) -> io::Result<()> {
66        shell::hand_over(payload)
67    }
68}
69
70/// Give up this process's claim on the foreground before handing a request to
71/// the instance that will act on it.
72///
73/// The seam is here rather than in `shell` because `main` is the caller and
74/// `shell` is this module's own. What it is for is in `shell`.
75#[cfg(target_os = "windows")]
76pub fn hand_the_foreground_on() {
77    shell::hand_the_foreground_on();
78}
79
80#[cfg(target_os = "windows")]
81mod shell {
82    //! Handing a payload to the shell, with Mark of the Web still consulted.
83    //!
84    //! ## `IAttachmentExecute` is not what reads the mark, and this was measured
85    //!
86    //! Concept 12 names `IAttachmentExecute` for both the launch and the trust
87    //! zone, and the stub this replaced repeated it. It is the wrong instrument
88    //! for both halves of what this tool does, which a probe settled on
89    //! 2026-09-01 by asking `CheckPolicy` about ten files — marked and unmarked,
90    //! across five extensions — and reading the raw `HRESULT` rather than the
91    //! `Result<()>` the bindings collapse it into:
92    //!
93    //! | | no source | internet source | `file://` source |
94    //! |---|---|---|---|
95    //! | `.txt`, marked or not | `S_FALSE` | `S_FALSE` | `S_OK` |
96    //! | `.pdf`, marked or not | `S_FALSE` | `S_FALSE` | `S_OK` |
97    //! | `.exe`, marked or not | `0x800C000E` | `S_FALSE` | `S_OK` |
98    //!
99    //! The marked and unmarked rows are identical in every column. The answer
100    //! moves with `SetSource` and with the extension, and never with the
101    //! `Zone.Identifier` stream on the file. That is the interface working as
102    //! intended rather than failing: it is for a client that has *received* an
103    //! attachment and is deciding whether to save and run it, so the zone comes
104    //! from the source it is told about. This tool arrives after that: the
105    //! payload is on disk and already carries its mark, put there by
106    //! `slpc::provenance` as `extract` placed it.
107    //!
108    //! ## What does read it, and the requirement that is therefore a negative
109    //!
110    //! `ShellExecuteEx` performs the zone check itself, and the evidence is that
111    //! `SEE_MASK_NOZONECHECKS` exists to turn it off — a flag documented as
112    //! bypassing "zone checking put into place by `IAttachmentExecute`", which
113    //! would have nothing to bypass if the check were opt-in. `SEE_MASK_FLAG_NO_UI`
114    //! is the other way to lose it, by suppressing the dialog it would raise.
115    //!
116    //! So the security-relevant instruction here is not a call to make but two
117    //! flags never to set, which is a weaker thing to rely on than a call and is
118    //! why [`MASK`] is a named constant with a test over it rather than a literal
119    //! at the call site. A future flag added for an unrelated reason is exactly
120    //! how this would be lost.
121    //!
122    //! **Not measured, and it needs a person at a desktop:** that the warning is
123    //! actually shown for a marked payload. Nothing automated can watch a modal
124    //! dialog, and the suite never reaches this function: the recording launcher
125    //! in `platform::testing` is what every test launches through, on all three
126    //! platforms.
127    //! `packaging/README.md` is where a run of it belongs once there is one.
128    //!
129    //! ## Why a thread
130    //!
131    //! The dialogs this call may raise are modal and unbounded: the zone warning
132    //! waits for an answer, and so does the *how do you want to open this*
133    //! picker when nothing is registered. `resident::run` is what pumps the
134    //! watchers, and concept 8 makes those the reason the process exists, so it
135    //! is the one thread that may not wait on a person.
136    //!
137    //! What that costs is a failure after the handover going unreported, and the
138    //! Unix arm already pays it: `spawn_detached` starts `xdg-open` and drops
139    //! the result, so a missing handler is silent there too. The parity is
140    //! deliberate rather than convenient. Reporting it would need concept 9's
141    //! channel reachable from another thread, which it is not.
142
143    use std::io;
144    use std::os::windows::ffi::OsStrExt as _;
145    use std::path::Path;
146
147    use windows::core::PCWSTR;
148    use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
149    use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW};
150    use windows::Win32::UI::WindowsAndMessaging::{
151        AllowSetForegroundWindow, ASFW_ANY, SW_SHOWNORMAL,
152    };
153
154    /// What is asked of `ShellExecuteEx`, and more to the point what is not.
155    ///
156    /// `SEE_MASK_NOASYNC` because this thread has no message loop and the call
157    /// has to finish its association work before the thread ends. Neither
158    /// `SEE_MASK_NOZONECHECKS` nor `SEE_MASK_FLAG_NO_UI` is here, and the module
159    /// documentation says why that absence is the whole trust-zone story.
160    pub(super) const MASK: u32 = SEE_MASK_NOASYNC;
161
162    /// Give up this process's claim on the foreground, so that whoever acts
163    /// next may take it.
164    ///
165    /// **Which process is holding the right is not the one doing the launching,
166    /// and that is the whole of why this exists.** Concept 8 makes every
167    /// invocation a client of a resident instance, so a double-click starts a
168    /// process that hands its request over and exits. The shell activated
169    /// *that* process, so it is the one Windows will let change the foreground
170    /// — and the instance, which is what actually calls `ShellExecuteEx`, has
171    /// been sitting in the background since the first container was opened and
172    /// may not.
173    ///
174    /// Measured on 2026-09-02, and it is exactly this shape: the first
175    /// double-click put the payload in front, because that invocation *was* the
176    /// instance; every one after it opened the payload behind the window the
177    /// person was looking at, because the instance by then was somebody else's
178    /// old process.
179    ///
180    /// `ASFW_ANY` rather than naming the instance: the client would have to ask
181    /// the pipe who is serving it, and the answer would still be wrong half the
182    /// time — concept 6 says a payload frequently goes to an application that
183    /// is already running, so the process which ends up in front is neither the
184    /// client nor the instance.
185    #[allow(unsafe_code)]
186    pub(super) fn hand_the_foreground_on() {
187        // SAFETY: gives away a right this process holds, takes no pointer, and
188        // returns a bool that means nothing here — a client which never had the
189        // right has none to lose.
190        let _ = unsafe { AllowSetForegroundWindow(ASFW_ANY) };
191    }
192
193    /// Hand `payload` to whatever the shell says opens it, and stop caring.
194    ///
195    /// # Errors
196    ///
197    /// Where the thread that does the handing cannot be started. Anything the
198    /// shell itself refuses is not reported, for the reason in the module
199    /// documentation.
200    pub(super) fn hand_over(payload: &Path) -> io::Result<()> {
201        // Widened here rather than in the thread, so that a path this process
202        // can see is what gets sent rather than one resolved later.
203        let path: Vec<u16> = payload
204            .as_os_str()
205            .encode_wide()
206            .chain(std::iter::once(0))
207            .collect();
208        std::thread::Builder::new()
209            .name("slipcase-open launch".to_owned())
210            .spawn(move || {
211                let _ = execute(&path);
212            })
213            .map(drop)
214    }
215
216    /// The call itself, on a thread of its own.
217    #[allow(unsafe_code)]
218    fn execute(path: &[u16]) -> io::Result<()> {
219        let _apartment = Apartment::enter();
220
221        // Hand our right to the foreground to whatever is about to be
222        // started. Windows refuses a foreground change from a process that does
223        // not have it, and the refusal is silent: the document opens *behind*
224        // whatever the person was looking at. Measured on 2026-09-02 — a
225        // container double-clicked in Explorer opened its payload behind the
226        // Explorer window — and this is the documented way to pass the right
227        // on, since this process was itself activated by that double-click.
228        //
229        // `ASFW_ANY` rather than a process id, because there is none to name:
230        // concept 6 says half of real applications hand the file to an instance
231        // that is already running, so the process that ends up with the
232        // foreground is frequently not the one this call starts.
233        //
234        // SAFETY: a permission handed to the shell for the length of this
235        // call, taking no pointer and returning a bool this code ignores by
236        // design — a refusal leaves the window where it would have been.
237        let _ = unsafe { AllowSetForegroundWindow(ASFW_ANY) };
238
239        let mut how = SHELLEXECUTEINFOW {
240            cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
241            fMask: MASK,
242            // Null is the default verb, which is what a double-click invokes.
243            // Naming `open` would be narrower and would refuse the types whose
244            // registration calls its default something else.
245            lpVerb: PCWSTR::null(),
246            lpFile: PCWSTR(path.as_ptr()),
247            nShow: SW_SHOWNORMAL.0,
248            ..Default::default()
249        };
250        // SAFETY: `how` is a live, correctly sized structure this call fills in,
251        // and `path` is null-terminated by `hand_over` and outlives the call
252        // because `SEE_MASK_NOASYNC` means it does not return early.
253        unsafe { ShellExecuteExW(&raw mut how) }.map_err(|_| io::Error::last_os_error())
254    }
255
256    /// COM for the length of one launch.
257    ///
258    /// `ShellExecuteEx` hands work to shell extensions and some of them require
259    /// a single-threaded apartment, so this thread enters one. Whether it leaves
260    /// again is not the same question: `S_FALSE` means somebody had already
261    /// entered on this thread and still owes a matching exit, where
262    /// `RPC_E_CHANGED_MODE` means they chose another model and this code must
263    /// not undo it.
264    struct Apartment(bool);
265
266    impl Apartment {
267        #[allow(unsafe_code)]
268        fn enter() -> Self {
269            // SAFETY: the documented entry point, with no reserved parameter.
270            let how = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
271            Self(how.is_ok())
272        }
273    }
274
275    impl Drop for Apartment {
276        #[allow(unsafe_code)]
277        fn drop(&mut self) {
278            if self.0 {
279                // SAFETY: paired with the successful `CoInitializeEx` above, on
280                // the same thread, which is what this call requires.
281                unsafe { CoUninitialize() };
282            }
283        }
284    }
285
286    #[cfg(test)]
287    mod tests {
288        use super::MASK;
289        use windows::Win32::UI::Shell::{SEE_MASK_FLAG_NO_UI, SEE_MASK_NOZONECHECKS};
290
291        #[test]
292        fn the_zone_check_is_never_opted_out_of() {
293            // The one thing about this arm that can be tested without a person
294            // watching a dialog, and the one worth a regression test: both of
295            // these are ways to lose Mark of the Web, and neither is loud when
296            // it happens. A flag added later for an unrelated reason is how the
297            // trust zone would go quiet.
298            assert_eq!(
299                MASK & SEE_MASK_NOZONECHECKS,
300                0,
301                "the zone check has been opted out of"
302            );
303            assert_eq!(
304                MASK & SEE_MASK_FLAG_NO_UI,
305                0,
306                "the warning the zone check raises has been suppressed"
307            );
308        }
309    }
310}
311
312#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
313impl Launcher for Host {
314    fn launch(&self, _payload: &Path) -> io::Result<()> {
315        Err(io::Error::new(
316            io::ErrorKind::Unsupported,
317            "no launcher for this platform",
318        ))
319    }
320}
321
322/// Start the launcher and stop caring about it.
323///
324/// The child's own streams go nowhere: `xdg-open` and `open` both write
325/// diagnostics that belong to them rather than to this tool, and inheriting
326/// them would put another program's complaints in the middle of a session
327/// report.
328///
329/// Waited on only far enough to reap it. These launchers exit immediately
330/// whether or not the document opened, so the exit status says the launcher ran
331/// and nothing about the application — which is the same reason concept 6 will
332/// not take process exit as a save signal.
333#[cfg(unix)]
334fn spawn_detached(program: &str, payload: &Path) -> io::Result<()> {
335    use std::process::{Command, Stdio};
336    let mut child = Command::new(program)
337        .arg(payload)
338        .stdin(Stdio::null())
339        .stdout(Stdio::null())
340        .stderr(Stdio::null())
341        .spawn()?;
342    // Not `wait`: `xdg-open` may block for as long as the application it
343    // started, on the desktops where it execs rather than forks.
344    let _ = child.try_wait();
345    Ok(())
346}
347
348#[cfg(test)]
349pub mod testing {
350    //! A launcher that records rather than launches, so the flow can be tested
351    //! without a desktop.
352
353    use super::Launcher;
354    use std::io;
355    use std::path::{Path, PathBuf};
356    use std::sync::Mutex;
357
358    /// Remembers what it was asked to open.
359    #[derive(Default)]
360    pub struct Recording {
361        launched: Mutex<Vec<PathBuf>>,
362        /// What to answer with, for the arm where the desktop refuses.
363        refuse: bool,
364    }
365
366    impl Recording {
367        /// A launcher that refuses everything, for the arm where the platform
368        /// has no handler or will not run one.
369        #[must_use]
370        pub fn refusing() -> Self {
371            Self {
372                refuse: true,
373                ..Self::default()
374            }
375        }
376
377        /// Everything it was handed, in order.
378        ///
379        /// # Panics
380        ///
381        /// If a previous caller panicked while holding the lock, which in a
382        /// test means the test that did so has already failed.
383        #[must_use]
384        pub fn launched(&self) -> Vec<PathBuf> {
385            self.launched.lock().unwrap().clone()
386        }
387    }
388
389    impl Launcher for Recording {
390        fn launch(&self, payload: &Path) -> io::Result<()> {
391            if self.refuse {
392                return Err(io::Error::new(io::ErrorKind::NotFound, "no handler"));
393            }
394            self.launched.lock().unwrap().push(payload.to_owned());
395            Ok(())
396        }
397    }
398}