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