Skip to main content

rightkit_tauri_shell/
site.rs

1//! Site-root serving: a second custom scheme (`<app>-site://`) that serves allowlisted
2//! subresources beneath a granted site root.
3//!
4//! Donor: ViewRight `site_root.rs` (root inference, MIME allowlist, containment checked after
5//! canonicalization, opaque per-grant tokens) and `stream_http.rs`.
6//!
7//! Flow: the app grants a document it already holds through the broker (the
8//! `rk_shell_site_grant` command, or [`grant_document_site`]). The shell infers a site root (the
9//! nearest enclosing project marker, never a filesystem root or the home directory, falling back to
10//! the document's own folder), mints an opaque token and returns `<scheme>://localhost/<token>/`.
11//! A document whose `<base href>` points there resolves `src`, CSS `url()`, fonts and scripts
12//! natively.
13//!
14//! Security model:
15//! * Tokens are opaque and per grant. [`PathBroker::revoke_site_root`] ends one permanently.
16//! * A request is percent-decoded, refused when it carries `..`, a root or a prefix component,
17//!   canonicalized, and re-checked against the canonical root, so a symlink that leaves the tree
18//!   fails after resolution.
19//! * Denied file names (by default the dotenv family and the npm and netrc credential files;
20//!   configurable on [`SitePolicy`]) are refused as requested and again after symlink resolution.
21//! * Only the MIME allowlist ([`site_mime`]) is served, with `nosniff` on every body. Page script
22//!   gets no `fetch()` access to this scheme (the app's CSP decides that).
23//! * A site root authorises this protocol only. It is not a file grant: the document commands
24//!   still refuse any file the person did not hand to the app.
25
26use 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
34/// Directory names that mark a plausible project root when walking up from a document.
35pub const ROOT_MARKERS: [&str; 3] = [".git", ".hg", ".svn"];
36
37/// File names that mark a plausible project root when walking up from a document.
38pub const ROOT_MARKER_FILES: [&str; 5] = [
39    "package.json",
40    "pnpm-workspace.yaml",
41    "Cargo.toml",
42    "go.mod",
43    "pyproject.toml",
44];
45
46/// How many directory levels above the document are considered. Bounds both the stat cost of
47/// inference and how far a single grant can reach.
48pub const MAX_ROOT_DEPTH: usize = 12;
49
50/// Policy for site serving: which names are refused and which extra markers mark a root.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SitePolicy {
53    /// File names refused regardless of extension. Each entry is an exact name, or a prefix ending
54    /// in `*`. Matching is case-insensitive against the final path component.
55    pub denied_names: Vec<String>,
56    /// Extra file or directory names (beside the built-in project markers) that mark a site root.
57    pub root_markers: Vec<String>,
58}
59
60impl Default for SitePolicy {
61    fn default() -> Self {
62        // Credential and environment files a page must never be able to read back.
63        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    /// Refuse one more file name (exact, or a prefix ending in `*`).
78    pub fn deny(mut self, name: &str) -> Self {
79        self.denied_names.push(name.to_owned());
80        self
81    }
82
83    /// Treat one more name as a root marker.
84    pub fn marker(mut self, name: &str) -> Self {
85        self.root_markers.push(name.to_owned());
86        self
87    }
88
89    /// True when `name` is refused by the denied-name list.
90    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
102/// MIME allowlist for site subresources. Deliberately narrow: an extension absent from this table
103/// is refused (415) rather than served as `application/octet-stream`.
104pub 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
141/// The user's home directory, used only to refuse it as a site root.
142fn 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
149/// True when `dir` is too broad to ever be a site root: a filesystem root, a bare volume root, or
150/// the user's home directory.
151fn 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
173/// Infer the site root for a document: the nearest enclosing directory with a project marker,
174/// searching upward at most [`MAX_ROOT_DEPTH`] levels and never returning a directory
175/// [`is_too_broad`] rejects. Falls back to the document's own directory (the pre-existing
176/// behaviour), so a loose HTML file grants no more than before.
177pub 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/// The result of [`infer_site_root_report`].
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct InferredSiteRoot {
184    /// The inferred root (canonical).
185    pub root: PathBuf,
186    /// True when `root` is a directory that carries a project marker; false when inference found
187    /// none and fell back to the document's own folder.
188    pub marker_found: bool,
189}
190
191/// Like [`infer_site_root`], and also reports whether a project marker was found, so an app can
192/// ask the person to confirm a fallback folder (see [`validate_chosen_site_root`]).
193pub 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
217/// Validate a directory the person chose as a site root (for example from a folder dialog):
218/// it must exist, be a directory, and not be too broad (a filesystem or volume root, or the home
219/// directory). Returns the canonical path. This is the check the shell applies to a root it
220/// accepts; apps call it before [`PathBroker::grant_site_root`].
221pub 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
234/// Split a request path `/<token>/<relative path>` into `(token, percent-decoded relative path)`.
235pub 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
244/// Minimal percent-decoder for URL paths. An invalid escape passes through literally, so a file
245/// whose name contains a bare `%` still resolves.
246pub 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
265/// Resolve `rel` beneath `root`. Refuses `..`, root and prefix components and denied names up
266/// front, then canonicalizes and re-checks containment and the denied name on the real target, so
267/// a symlink pointing outside the tree (or at a denied file) fails.
268pub 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/// A site grant: the opaque token and the directory it serves.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct SiteGrant {
315    pub token: String,
316    pub root: PathBuf,
317}
318
319/// Grant the site root of a document the person already opened. The document must be a granted
320/// file; its root is inferred with `policy`, and the grant covers only this protocol.
321pub 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
336/// The base URL a document uses for a site token (`<base href>`), per platform convention.
337pub 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
349/// Serve one `<scheme>://localhost/<token>/<relative path>` request. Validation (token, containment,
350/// denied names, MIME, stat, range) is synchronous; only the body read runs on a worker thread.
351/// `respond` runs exactly once.
352pub 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        // A policy refusal (403) stays distinct from a missing file (404) so the UI never reports
372        // a containment failure as "not found".
373        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}