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 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
49pub 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 #[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
87pub 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 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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}