Skip to main content

agentmux_srv/backend/
docsite.rs

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