1use std::collections::{HashMap, HashSet, VecDeque};
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15
16use crate::error::ShellError;
17
18pub const KNOWN_TOKEN_CAP: usize = 256;
20pub 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
47pub 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
83pub 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 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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}