agentmux_srv\backend/
docsite.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Documentation site path resolution.
6//! Port of Go's pkg/docsite/.
7//!
8//! Resolves paths to the bundled documentation site directory
9//! with automatic .html extension fallback.
10
11
12use std::path::PathBuf;
13use std::sync::OnceLock;
14
15// ---- Docsite resolution ----
16
17/// Cached docsite directory path.
18static DOCSITE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
19
20/// Set the docsite directory path (typically `<app_path>/docsite`).
21///
22/// Must be called before `get_docsite_dir`. Once set, the path is cached.
23pub fn set_docsite_dir(path: PathBuf) {
24    let _ = DOCSITE_DIR.set(if path.is_dir() { Some(path) } else { None });
25}
26
27/// Get the cached docsite directory, if it exists.
28pub fn get_docsite_dir() -> Option<&'static PathBuf> {
29    DOCSITE_DIR.get().and_then(|opt| opt.as_ref())
30}
31
32/// Resolve a request path to a file in the docsite directory.
33///
34/// Tries the exact path first, then appends `.html` as fallback.
35/// Returns `None` if no matching file exists or docsite is not configured.
36///
37/// # Examples
38///
39/// ```
40/// use backend_test::backend::docsite::resolve_docsite_path;
41///
42/// // Without docsite configured, always returns None
43/// assert!(resolve_docsite_path("/docs/foo").is_none());
44/// ```
45pub fn resolve_docsite_path(request_path: &str) -> Option<PathBuf> {
46    let base = get_docsite_dir()?;
47
48    // Strip leading slash and normalize
49    let clean = request_path.trim_start_matches('/');
50    if clean.is_empty() {
51        // Try index.html
52        let index = base.join("index.html");
53        if index.is_file() {
54            return Some(index);
55        }
56        return None;
57    }
58
59    // Prevent path traversal
60    if clean.contains("..") {
61        return None;
62    }
63
64    // Try exact path
65    let exact = base.join(clean);
66    if exact.is_file() {
67        return Some(exact);
68    }
69
70    // Try with .html extension
71    let with_html = base.join(format!("{}.html", clean));
72    if with_html.is_file() {
73        return Some(with_html);
74    }
75
76    None
77}
78
79/// Check if a path component is safe (no traversal).
80pub fn is_safe_path(path: &str) -> bool {
81    !path.contains("..") && !path.contains('\0')
82}
83
84// ---- Tests ----
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_resolve_no_docsite() {
92        // Without setting docsite dir, should return None
93        assert!(resolve_docsite_path("/anything").is_none());
94    }
95
96    #[test]
97    fn test_is_safe_path() {
98        assert!(is_safe_path("docs/page"));
99        assert!(is_safe_path("index.html"));
100        assert!(!is_safe_path("../etc/passwd"));
101        assert!(!is_safe_path("docs/../../secret"));
102        assert!(!is_safe_path("path\0with\0null"));
103    }
104
105    #[test]
106    fn test_resolve_blocks_traversal() {
107        // Even if docsite were set, traversal should be blocked
108        assert!(resolve_docsite_path("/../../../etc/passwd").is_none());
109    }
110}