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}