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