1use std::path::{Component, Path, PathBuf};
27
28use tauri::http::{header, Method, Request, Response, StatusCode};
29
30use crate::broker::{path_to_string, PathBroker};
31use crate::error::ShellError;
32use crate::stream::{cors, empty, finish, method_gate_status, parse_range, ranged, read_range};
33
34pub const ROOT_MARKERS: [&str; 3] = [".git", ".hg", ".svn"];
36
37pub const ROOT_MARKER_FILES: [&str; 5] = [
39 "package.json",
40 "pnpm-workspace.yaml",
41 "Cargo.toml",
42 "go.mod",
43 "pyproject.toml",
44];
45
46pub const MAX_ROOT_DEPTH: usize = 12;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SitePolicy {
53 pub denied_names: Vec<String>,
56 pub root_markers: Vec<String>,
58}
59
60impl Default for SitePolicy {
61 fn default() -> Self {
62 let denied = [
64 ".env".to_owned(),
65 ".env.*".to_owned(),
66 ".npmrc".to_owned(),
67 ".netrc".to_owned(),
68 ];
69 Self {
70 denied_names: denied.to_vec(),
71 root_markers: Vec::new(),
72 }
73 }
74}
75
76impl SitePolicy {
77 pub fn deny(mut self, name: &str) -> Self {
79 self.denied_names.push(name.to_owned());
80 self
81 }
82
83 pub fn marker(mut self, name: &str) -> Self {
85 self.root_markers.push(name.to_owned());
86 self
87 }
88
89 pub fn is_denied(&self, name: &str) -> bool {
91 let lower = name.to_ascii_lowercase();
92 self.denied_names.iter().any(|pattern| {
93 let pattern = pattern.to_ascii_lowercase();
94 match pattern.strip_suffix('*') {
95 Some(prefix) => lower.starts_with(prefix),
96 None => lower == pattern,
97 }
98 })
99 }
100}
101
102pub fn site_mime(ext: &str) -> Option<&'static str> {
105 Some(match ext.to_ascii_lowercase().as_str() {
106 "css" => "text/css",
107 "png" => "image/png",
108 "jpg" | "jpeg" => "image/jpeg",
109 "gif" => "image/gif",
110 "webp" => "image/webp",
111 "avif" => "image/avif",
112 "svg" => "image/svg+xml",
113 "ico" => "image/x-icon",
114 "bmp" => "image/bmp",
115 "apng" => "image/apng",
116 "woff" => "font/woff",
117 "woff2" => "font/woff2",
118 "ttf" => "font/ttf",
119 "otf" => "font/otf",
120 "eot" => "application/vnd.ms-fontobject",
121 "wav" => "audio/wav",
122 "mp3" => "audio/mpeg",
123 "m4a" => "audio/mp4",
124 "aac" => "audio/aac",
125 "flac" => "audio/flac",
126 "ogg" | "oga" => "audio/ogg",
127 "webm" => "video/webm",
128 "mp4" | "m4v" => "video/mp4",
129 "mov" => "video/quicktime",
130 "ogv" => "video/ogg",
131 "vtt" => "text/vtt",
132 "srt" => "application/x-subrip",
133 "js" | "mjs" | "cjs" => "text/javascript",
134 "json" => "application/json",
135 "map" => "application/json",
136 "wasm" => "application/wasm",
137 _ => return None,
138 })
139}
140
141fn home_dir() -> Option<PathBuf> {
143 std::env::var_os("HOME")
144 .or_else(|| std::env::var_os("USERPROFILE"))
145 .map(PathBuf::from)
146 .filter(|p| !p.as_os_str().is_empty())
147}
148
149fn is_too_broad(dir: &Path) -> bool {
152 if dir.parent().is_none() {
153 return true;
154 }
155 let depth = dir
156 .components()
157 .filter(|c| matches!(c, Component::Normal(_)))
158 .count();
159 if depth == 0 {
160 return true;
161 }
162 home_dir().is_some_and(|home| {
163 std::fs::canonicalize(&home).is_ok_and(|home| home == dir) || home == dir
164 })
165}
166
167fn has_root_marker(dir: &Path, policy: &SitePolicy) -> bool {
168 ROOT_MARKERS.iter().any(|m| dir.join(m).exists())
169 || policy.root_markers.iter().any(|m| dir.join(m).exists())
170 || ROOT_MARKER_FILES.iter().any(|m| dir.join(m).is_file())
171}
172
173pub fn infer_site_root(doc_path: &Path, policy: &SitePolicy) -> Option<PathBuf> {
178 infer_site_root_report(doc_path, policy).map(|found| found.root)
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct InferredSiteRoot {
184 pub root: PathBuf,
186 pub marker_found: bool,
189}
190
191pub fn infer_site_root_report(doc_path: &Path, policy: &SitePolicy) -> Option<InferredSiteRoot> {
194 let doc_dir = std::fs::canonicalize(doc_path.parent()?).ok()?;
195 let mut candidate = doc_dir.as_path();
196 for _ in 0..MAX_ROOT_DEPTH {
197 if is_too_broad(candidate) {
198 break;
199 }
200 if has_root_marker(candidate, policy) {
201 return Some(InferredSiteRoot {
202 root: candidate.to_path_buf(),
203 marker_found: true,
204 });
205 }
206 match candidate.parent() {
207 Some(parent) => candidate = parent,
208 None => break,
209 }
210 }
211 Some(InferredSiteRoot {
212 root: doc_dir,
213 marker_found: false,
214 })
215}
216
217pub fn validate_chosen_site_root(dir: &Path) -> Result<PathBuf, ShellError> {
222 let canonical = std::fs::canonicalize(dir)?;
223 if !canonical.is_dir() {
224 return Err(ShellError::Invalid("site root is not a directory".into()));
225 }
226 if is_too_broad(&canonical) {
227 return Err(ShellError::BrokerDenied(
228 "site root is too broad (a filesystem root or the home directory)".into(),
229 ));
230 }
231 Ok(canonical)
232}
233
234pub fn split_site_request(uri_path: &str) -> Option<(String, String)> {
236 let trimmed = uri_path.trim_start_matches('/');
237 let (token, rest) = trimmed.split_once('/')?;
238 if token.is_empty() || rest.is_empty() {
239 return None;
240 }
241 Some((token.to_string(), percent_decode(rest)))
242}
243
244pub fn percent_decode(input: &str) -> String {
247 let bytes = input.as_bytes();
248 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
249 let mut i = 0;
250 while i < bytes.len() {
251 if bytes[i] == b'%' && i + 2 < bytes.len() {
252 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
253 if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
254 out.push(byte);
255 i += 3;
256 continue;
257 }
258 }
259 out.push(bytes[i]);
260 i += 1;
261 }
262 String::from_utf8_lossy(&out).into_owned()
263}
264
265pub fn resolve_in_root(root: &Path, rel: &str, policy: &SitePolicy) -> Result<PathBuf, ShellError> {
269 let rel = rel.trim_start_matches('/');
270 if rel.is_empty() {
271 return Err(ShellError::FileNotFound("empty asset path".into()));
272 }
273 if rel.contains('\0') {
274 return Err(ShellError::Invalid("path contains a NUL byte".into()));
275 }
276 let requested = Path::new(rel);
277 let escapes = requested.components().any(|c| {
278 matches!(
279 c,
280 Component::ParentDir | Component::Prefix(_) | Component::RootDir
281 )
282 });
283 if escapes {
284 return Err(ShellError::BrokerDenied(
285 "asset is outside the granted site root".into(),
286 ));
287 }
288 if requested
289 .file_name()
290 .and_then(|n| n.to_str())
291 .is_some_and(|n| policy.is_denied(n))
292 {
293 return Err(ShellError::BrokerDenied("asset type is not allowed".into()));
294 }
295 let target = std::fs::canonicalize(root.join(requested))?;
296 let root = std::fs::canonicalize(root)?;
297 if !target.starts_with(&root) {
298 return Err(ShellError::BrokerDenied(
299 "asset is outside the granted site root".into(),
300 ));
301 }
302 if target
303 .file_name()
304 .and_then(|n| n.to_str())
305 .is_some_and(|n| policy.is_denied(n))
306 {
307 return Err(ShellError::BrokerDenied("asset type is not allowed".into()));
308 }
309 Ok(target)
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct SiteGrant {
315 pub token: String,
316 pub root: PathBuf,
317}
318
319pub fn grant_document_site(
322 broker: &PathBroker,
323 document: &str,
324 policy: &SitePolicy,
325) -> Result<SiteGrant, ShellError> {
326 let granted = broker.require_file(document)?;
327 let root = infer_site_root(Path::new(&granted), policy)
328 .ok_or_else(|| ShellError::Invalid("document has no parent directory".into()))?;
329 let token = broker.grant_site_root(&root)?;
330 Ok(SiteGrant {
331 token,
332 root: PathBuf::from(path_to_string(&root)),
333 })
334}
335
336pub fn site_base_url(scheme: &str, token: &str) -> String {
338 if cfg!(windows) {
339 format!("http://{scheme}.localhost/{token}/")
340 } else {
341 format!("{scheme}://localhost/{token}/")
342 }
343}
344
345fn base_builder(status: StatusCode, mime: &str, len: u64) -> tauri::http::response::Builder {
346 ranged(status, mime, len).header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
347}
348
349pub fn serve_site_request<F>(
353 broker: &PathBroker,
354 policy: &SitePolicy,
355 request: &Request<Vec<u8>>,
356 respond: F,
357) where
358 F: FnOnce(Response<Vec<u8>>) + Send + 'static,
359{
360 if let Some(status) = method_gate_status(request.method()) {
361 return respond(empty(status));
362 }
363 let Some((token, rel)) = split_site_request(request.uri().path()) else {
364 return respond(empty(StatusCode::BAD_REQUEST));
365 };
366 let Ok(root) = broker.resolve_site_root(&token) else {
367 return respond(empty(StatusCode::FORBIDDEN));
368 };
369 let target = match resolve_in_root(&root, &rel, policy) {
370 Ok(target) => target,
371 Err(ShellError::BrokerDenied(_)) => return respond(empty(StatusCode::FORBIDDEN)),
374 Err(ShellError::Invalid(_)) => return respond(empty(StatusCode::BAD_REQUEST)),
375 Err(_) => return respond(empty(StatusCode::NOT_FOUND)),
376 };
377 let Some(mime) = target
378 .extension()
379 .and_then(|e| e.to_str())
380 .and_then(site_mime)
381 else {
382 return respond(empty(StatusCode::UNSUPPORTED_MEDIA_TYPE));
383 };
384 let Ok(meta) = std::fs::metadata(&target) else {
385 return respond(empty(StatusCode::NOT_FOUND));
386 };
387 if !meta.is_file() {
388 return respond(empty(StatusCode::NOT_FOUND));
389 }
390 let total = meta.len();
391 let is_head = request.method() == Method::HEAD;
392
393 if let Some(range_header) = request.headers().get(header::RANGE) {
394 let Some((start, end)) = range_header
395 .to_str()
396 .ok()
397 .and_then(|text| parse_range(text, total))
398 else {
399 return respond(finish(
400 cors(StatusCode::RANGE_NOT_SATISFIABLE)
401 .header(header::CONTENT_RANGE, format!("bytes */{total}")),
402 Vec::new(),
403 ));
404 };
405 let content_range = format!("bytes {start}-{end}/{total}");
406 if is_head {
407 return respond(finish(
408 base_builder(StatusCode::PARTIAL_CONTENT, mime, end - start + 1)
409 .header(header::CONTENT_RANGE, content_range),
410 Vec::new(),
411 ));
412 }
413 std::thread::spawn(move || {
414 let response = match read_range(&target, start, end) {
415 Ok(bytes) => finish(
416 base_builder(StatusCode::PARTIAL_CONTENT, mime, end - start + 1)
417 .header(header::CONTENT_RANGE, content_range),
418 bytes,
419 ),
420 Err(_) => empty(StatusCode::NOT_FOUND),
421 };
422 respond(response);
423 });
424 return;
425 }
426
427 if is_head {
428 return respond(finish(
429 base_builder(StatusCode::OK, mime, total),
430 Vec::new(),
431 ));
432 }
433 std::thread::spawn(move || {
434 let response = match std::fs::read(&target) {
435 Ok(bytes) => finish(base_builder(StatusCode::OK, mime, total), bytes),
436 Err(_) => empty(StatusCode::NOT_FOUND),
437 };
438 respond(response);
439 });
440}