Skip to main content

rightkit_tauri_shell/
broker.rs

1//! Path broker: the renderer never names a file the user did not hand to the app.
2//!
3//! Donor: ViewRight `path_broker.rs` (granted file set, save-target grants with a bounded
4//! write-target ring, one-shot recent-file tokens, stable per-file stream tokens, per-tab site
5//! roots). Differences from the donor: errors are [`ShellError`] values, random ids come straight
6//! from the OS entropy call with no extra crate, and the broker is a plain type any app can embed.
7//!
8//! Grants are made by Rust code (a file dialog result, a drag-drop, the launch argv, a recent-files
9//! table). The renderer can only *use* grants: check a path, mint a stream id for a granted file,
10//! trade a one-shot recent id. Nothing here lets the renderer grant an arbitrary path.
11
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15
16use crate::error::ShellError;
17
18/// Upper bound on live recent-file ids. Old ones are disposable (the renderer re-lists).
19pub const KNOWN_TOKEN_CAP: usize = 256;
20/// Ring of recently written save targets that `regrant_saved_target` may re-grant.
21pub const WRITE_TARGET_RING_CAP: usize = 8;
22
23#[derive(Default)]
24struct KnownTokens {
25    map: HashMap<String, PathBuf>,
26    order: VecDeque<String>,
27}
28
29#[derive(Default)]
30pub struct PathBroker {
31    allowed: Mutex<HashSet<PathBuf>>,
32    known: Mutex<KnownTokens>,
33    stream_tokens: Mutex<HashMap<String, PathBuf>>,
34    site_roots: Mutex<HashMap<String, PathBuf>>,
35    recent_writes: Mutex<VecDeque<PathBuf>>,
36    /// Name used in denial text ("through {name} first"); empty reads as "the app".
37    app_name: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct StreamTarget {
42    pub path: PathBuf,
43}
44
45fn poisoned<T>(_: T) -> ShellError {
46    ShellError::Other("path broker lock poisoned".into())
47}
48
49/// Display form of a normalised path (drops the Windows verbatim prefix).
50pub fn path_to_string(path: &Path) -> String {
51    let raw = path.to_string_lossy();
52    #[cfg(windows)]
53    {
54        if let Some(stripped) = raw.strip_prefix(r"\\?\UNC\") {
55            return format!(r"\\{stripped}");
56        }
57        if let Some(stripped) = raw.strip_prefix(r"\\?\") {
58            return stripped.to_string();
59        }
60    }
61    raw.into_owned()
62}
63
64fn display_matches(path: &Path, display: &str) -> bool {
65    let normalized = path_to_string(path);
66    // Windows and macOS volumes are case-insensitive by default, so a differently-cased display
67    // string must still match (a deleted file cannot be re-resolved to its true case).
68    #[cfg(any(windows, target_os = "macos"))]
69    {
70        normalized.to_lowercase() == display.to_lowercase()
71    }
72    #[cfg(not(any(windows, target_os = "macos")))]
73    {
74        normalized == display
75    }
76}
77
78pub fn normalize_existing_file(path: impl AsRef<Path>) -> Result<PathBuf, ShellError> {
79    let path = path.as_ref();
80    let meta = std::fs::metadata(path)?;
81    if !meta.is_file() {
82        return Err(ShellError::FileNotFound("path is not a file".into()));
83    }
84    Ok(std::fs::canonicalize(path)?)
85}
86
87/// Canonical form of a path that may not exist yet (a save target): the parent must exist.
88pub fn normalize_write_target(path: impl AsRef<Path>) -> Result<PathBuf, ShellError> {
89    let path = path.as_ref();
90    if path.as_os_str().is_empty() {
91        return Err(ShellError::Invalid("empty path".into()));
92    }
93    if let Ok(meta) = std::fs::metadata(path) {
94        if meta.is_dir() {
95            return Err(ShellError::Invalid("path is a directory".into()));
96        }
97        return Ok(std::fs::canonicalize(path)?);
98    }
99    let parent = path
100        .parent()
101        .filter(|p| !p.as_os_str().is_empty())
102        .unwrap_or_else(|| Path::new("."));
103    let parent = std::fs::canonicalize(parent)?;
104    let name = path
105        .file_name()
106        .ok_or_else(|| ShellError::Invalid("path has no file name".into()))?;
107    Ok(parent.join(name))
108}
109
110#[cfg(unix)]
111fn fill_entropy(buf: &mut [u8]) -> std::io::Result<()> {
112    extern "C" {
113        fn getentropy(buf: *mut std::ffi::c_void, len: usize) -> std::ffi::c_int;
114    }
115    // SAFETY: `buf` is a valid writable slice of at most 256 bytes.
116    if unsafe { getentropy(buf.as_mut_ptr().cast(), buf.len()) } == 0 {
117        Ok(())
118    } else {
119        Err(std::io::Error::last_os_error())
120    }
121}
122
123#[cfg(windows)]
124fn fill_entropy(buf: &mut [u8]) -> std::io::Result<()> {
125    #[link(name = "advapi32")]
126    extern "system" {
127        #[link_name = "SystemFunction036"]
128        fn RtlGenRandom(buf: *mut u8, len: u32) -> u8;
129    }
130    // SAFETY: `buf` is a valid writable slice for `len` bytes.
131    if unsafe { RtlGenRandom(buf.as_mut_ptr(), buf.len() as u32) } != 0 {
132        Ok(())
133    } else {
134        Err(std::io::Error::last_os_error())
135    }
136}
137
138fn random_id(prefix: &str) -> Result<String, ShellError> {
139    let mut bytes = [0_u8; 16];
140    fill_entropy(&mut bytes)
141        .map_err(|e| ShellError::Other(format!("entropy source unavailable: {e}")))?;
142    let mut id = String::with_capacity(prefix.len() + 32);
143    id.push_str(prefix);
144    for b in bytes {
145        use std::fmt::Write;
146        let _ = write!(id, "{b:02x}");
147    }
148    Ok(id)
149}
150
151fn mint_unique<V>(map: &HashMap<String, V>, prefix: &str) -> Result<String, ShellError> {
152    loop {
153        let t = random_id(prefix)?;
154        if !map.contains_key(&t) {
155            return Ok(t);
156        }
157    }
158}
159
160/// Strip the macOS quarantine attribute from a file the user explicitly chose to open, so
161/// Gatekeeper does not block later opens. Best effort; in-process `removexattr`, no fork.
162#[cfg(target_os = "macos")]
163fn clear_quarantine(path: &Path) {
164    use std::os::unix::ffi::OsStrExt;
165    extern "C" {
166        fn removexattr(
167            path: *const std::ffi::c_char,
168            name: *const std::ffi::c_char,
169            options: i32,
170        ) -> i32;
171    }
172    const XATTR_NOFOLLOW: i32 = 0x0001;
173    let Ok(p) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
174        return;
175    };
176    // SAFETY: both pointers are valid NUL-terminated strings for the duration of the call; the
177    // result (ENOATTR when the file was never quarantined) is intentionally ignored.
178    unsafe {
179        removexattr(p.as_ptr(), c"com.apple.quarantine".as_ptr(), XATTR_NOFOLLOW);
180    }
181}
182
183impl PathBroker {
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// A broker whose denial text names the app ("open it through {app_name} first"), taken from
189    /// the shell's `app_name` config. An empty name reads as "the app".
190    pub fn with_app_name(app_name: &str) -> Self {
191        Self {
192            app_name: app_name.trim().to_owned(),
193            ..Self::default()
194        }
195    }
196
197    fn app(&self) -> &str {
198        if self.app_name.is_empty() {
199            "the app"
200        } else {
201            &self.app_name
202        }
203    }
204
205    /// Grant an existing file the user chose. Returns the display path.
206    pub fn grant_file(&self, path: impl AsRef<Path>) -> Result<String, ShellError> {
207        let normalized = normalize_existing_file(path)?;
208        #[cfg(target_os = "macos")]
209        clear_quarantine(&normalized);
210        self.grant_normalized(normalized)
211    }
212
213    /// Grant a path the user picked in a save dialog (it need not exist).
214    pub fn grant_save_target(&self, path: impl AsRef<Path>) -> Result<String, ShellError> {
215        self.grant_normalized(normalize_write_target(path)?)
216    }
217
218    pub fn grant_normalized(&self, path: PathBuf) -> Result<String, ShellError> {
219        let display = path_to_string(&path);
220        self.allowed.lock().map_err(poisoned)?.insert(path);
221        Ok(display)
222    }
223
224    pub fn is_granted_normalized(&self, path: &Path) -> Result<bool, ShellError> {
225        Ok(self.allowed.lock().map_err(poisoned)?.contains(path))
226    }
227
228    /// Canonicalise `path` and require it to be a granted existing file.
229    pub fn require_file(&self, path: &str) -> Result<String, ShellError> {
230        let normalized = normalize_existing_file(path)?;
231        if self.is_granted_normalized(&normalized)? {
232            Ok(path_to_string(&normalized))
233        } else {
234            Err(ShellError::BrokerDenied(format!(
235                "path is not authorized; open it through {} first",
236                self.app()
237            )))
238        }
239    }
240
241    pub fn require_write_target(&self, path: &str) -> Result<String, ShellError> {
242        let normalized = normalize_write_target(path)?;
243        if self.is_granted_normalized(&normalized)? {
244            Ok(path_to_string(&normalized))
245        } else {
246            Err(ShellError::BrokerDenied(format!(
247                "path is not authorized; choose it through {} first",
248                self.app()
249            )))
250        }
251    }
252
253    /// Record a path the app just wrote; evicts the oldest past [`WRITE_TARGET_RING_CAP`].
254    pub fn record_write_target(&self, path: &Path) -> Result<(), ShellError> {
255        let mut ring = self.recent_writes.lock().map_err(poisoned)?;
256        ring.retain(|p| p != path);
257        ring.push_back(path.to_path_buf());
258        while ring.len() > WRITE_TARGET_RING_CAP {
259            ring.pop_front();
260        }
261        Ok(())
262    }
263
264    pub fn was_recently_written(&self, path: &Path) -> Result<bool, ShellError> {
265        Ok(self
266            .recent_writes
267            .lock()
268            .map_err(poisoned)?
269            .iter()
270            .any(|p| p == path))
271    }
272
273    /// Re-grant a save-as target that was just written (so the renderer cannot ask for any
274    /// existing path to be granted).
275    pub fn regrant_saved_target(&self, path: &str) -> Result<String, ShellError> {
276        let normalized = normalize_write_target(path)?;
277        if !self.was_recently_written(&normalized)? {
278            return Err(ShellError::BrokerDenied(format!(
279                "path was not just written by the app; save it through {} first",
280                self.app()
281            )));
282        }
283        self.grant_normalized(normalized)
284    }
285
286    /// Mint a one-shot recent-file id for a stored path (re-canonicalised: older rows or platform
287    /// spelling differences must not grant something later checks reject).
288    pub fn register_known_path(&self, path: &str) -> Result<String, ShellError> {
289        let normalized = normalize_existing_file(path)?;
290        let mut known = self.known.lock().map_err(poisoned)?;
291        let id = mint_unique(&known.map, "known:")?;
292        known.map.insert(id.clone(), normalized);
293        known.order.push_back(id.clone());
294        while known.order.len() > KNOWN_TOKEN_CAP {
295            if let Some(old) = known.order.pop_front() {
296                known.map.remove(&old);
297            }
298        }
299        Ok(id)
300    }
301
302    /// Trade a recent-file id for a grant. One shot: replaying it fails.
303    pub fn grant_known(&self, id: &str) -> Result<String, ShellError> {
304        let normalized = self
305            .known
306            .lock()
307            .map_err(poisoned)?
308            .map
309            .remove(id)
310            .ok_or_else(|| {
311                ShellError::BrokerDenied("unknown or already-used recent-file id".into())
312            })?;
313        self.grant_normalized(normalized)
314    }
315
316    /// Revoke a grant (its last tab closed) and every stream id for that file, so old stream URLs
317    /// die now and never revive on reopen. Matches by display string, so a file deleted after
318    /// opening is still revoked.
319    pub fn revoke_path(&self, display: &str) -> Result<(), ShellError> {
320        let mut revoked = Vec::new();
321        self.allowed.lock().map_err(poisoned)?.retain(|p| {
322            let keep = !display_matches(p, display);
323            if !keep {
324                revoked.push(p.clone());
325            }
326            keep
327        });
328        if !revoked.is_empty() {
329            self.stream_tokens
330                .lock()
331                .map_err(poisoned)?
332                .retain(|_, p| !revoked.iter().any(|r| r == p));
333        }
334        Ok(())
335    }
336
337    /// Opaque URL id for a granted file. One stable live id per file: re-reading after an atomic
338    /// edit overlaps in-flight range requests, and rotating would 403 the URL still loading.
339    pub fn grant_stream(&self, path: &str) -> Result<String, ShellError> {
340        let normalized = normalize_existing_file(path)?;
341        let allowed = self.allowed.lock().map_err(poisoned)?;
342        if !allowed.contains(&normalized) {
343            return Err(ShellError::BrokerDenied(format!(
344                "path is not authorized; open it through {} first",
345                self.app()
346            )));
347        }
348        let mut streams = self.stream_tokens.lock().map_err(poisoned)?;
349        if let Some((id, _)) = streams.iter().find(|(_, p)| *p == &normalized) {
350            return Ok(id.clone());
351        }
352        let id = mint_unique(&streams, "stream-")?;
353        streams.insert(id.clone(), normalized);
354        Ok(id)
355    }
356
357    pub fn resolve_stream(&self, id: &str) -> Result<StreamTarget, ShellError> {
358        let path = self
359            .stream_tokens
360            .lock()
361            .map_err(poisoned)?
362            .get(id)
363            .cloned()
364            .ok_or_else(|| ShellError::BrokerDenied("unknown or revoked stream id".into()))?;
365        Ok(StreamTarget { path })
366    }
367
368    /// Opaque id for a directory the preview may read subresources from. Authorises only
369    /// containment-checked reads through [`PathBroker::resolve_in_site_root`], never general
370    /// document access. One id per grant (not deduplicated by directory) so two tabs on the same
371    /// project do not revoke each other.
372    pub fn grant_site_root(&self, dir: impl AsRef<Path>) -> Result<String, ShellError> {
373        let dir = std::fs::canonicalize(dir.as_ref())?;
374        if !dir.is_dir() {
375            return Err(ShellError::Invalid("site root is not a directory".into()));
376        }
377        let mut roots = self.site_roots.lock().map_err(poisoned)?;
378        let id = mint_unique(&roots, "site-")?;
379        roots.insert(id.clone(), dir);
380        Ok(id)
381    }
382
383    pub fn resolve_site_root(&self, id: &str) -> Result<PathBuf, ShellError> {
384        self.site_roots
385            .lock()
386            .map_err(poisoned)?
387            .get(id)
388            .cloned()
389            .ok_or_else(|| ShellError::BrokerDenied("unknown or revoked site root id".into()))
390    }
391
392    /// Resolve `relative` under a site root; refuses anything that escapes it (`..`, symlinks).
393    pub fn resolve_in_site_root(&self, id: &str, relative: &str) -> Result<PathBuf, ShellError> {
394        let root = self.resolve_site_root(id)?;
395        let rel = relative.trim_start_matches('/');
396        let escapes = rel.contains('\0')
397            || Path::new(rel).components().any(|c| {
398                matches!(
399                    c,
400                    std::path::Component::ParentDir
401                        | std::path::Component::Prefix(_)
402                        | std::path::Component::RootDir
403                )
404            });
405        if escapes {
406            return Err(ShellError::BrokerDenied(
407                "path escapes the site root".into(),
408            ));
409        }
410        let full = std::fs::canonicalize(root.join(rel))?;
411        if !full.starts_with(&root) {
412            return Err(ShellError::BrokerDenied(
413                "path escapes the site root".into(),
414            ));
415        }
416        Ok(full)
417    }
418
419    pub fn revoke_site_root(&self, id: &str) -> Result<(), ShellError> {
420        self.site_roots.lock().map_err(poisoned)?.remove(id);
421        Ok(())
422    }
423}