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}