Skip to main content

statespace_server/
content.rs

1//! Content resolution from a content root directory.
2
3use async_trait::async_trait;
4use statespace_tool_runtime::Error;
5use std::path::{Path, PathBuf};
6use tokio::fs;
7
8use crate::semantics::markdown_lookup_candidates;
9
10#[async_trait]
11pub trait ContentResolver: Send + Sync {
12    async fn resolve(&self, path: &str) -> Result<String, Error>;
13    async fn resolve_path(&self, path: &str) -> Result<PathBuf, Error>;
14}
15
16#[derive(Debug)]
17pub struct LocalContentResolver {
18    root: PathBuf,
19}
20
21impl LocalContentResolver {
22    /// # Errors
23    ///
24    /// Returns an error if the root path cannot be canonicalized.
25    pub fn new(root: &Path) -> Result<Self, Error> {
26        let root = root.canonicalize().map_err(|e| {
27            Error::Io(std::io::Error::new(
28                std::io::ErrorKind::InvalidInput,
29                format!("Failed to canonicalize root path: {e}"),
30            ))
31        })?;
32        Ok(Self { root })
33    }
34
35    #[must_use]
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    fn validate_path(&self, requested: &str) -> Result<PathBuf, Error> {
41        let requested = requested.trim_start_matches('/');
42
43        if requested.contains("..") {
44            return Err(Error::PathTraversal {
45                attempted: requested.to_string(),
46                boundary: self.root.to_string_lossy().to_string(),
47            });
48        }
49
50        let target = if requested.is_empty() {
51            self.root.clone()
52        } else {
53            self.root.join(requested)
54        };
55
56        Ok(target)
57    }
58
59    fn resolve_to_file(root: &Path, original: &str) -> Result<PathBuf, Error> {
60        for candidate in markdown_lookup_candidates(original) {
61            let candidate_path = root.join(candidate);
62            if candidate_path.is_file() {
63                return Ok(candidate_path);
64            }
65        }
66
67        Err(Error::NotFound(original.to_string()))
68    }
69}
70
71#[async_trait]
72impl ContentResolver for LocalContentResolver {
73    async fn resolve(&self, path: &str) -> Result<String, Error> {
74        self.validate_path(path)?;
75        let resolved = Self::resolve_to_file(&self.root, path)?;
76
77        let resolved = resolved
78            .canonicalize()
79            .map_err(|_err| Error::NotFound(path.to_string()))?;
80        if !resolved.starts_with(&self.root) {
81            return Err(Error::PathTraversal {
82                attempted: path.to_string(),
83                boundary: self.root.to_string_lossy().to_string(),
84            });
85        }
86
87        fs::read_to_string(&resolved).await.map_err(Error::Io)
88    }
89
90    async fn resolve_path(&self, path: &str) -> Result<PathBuf, Error> {
91        self.validate_path(path)?;
92        let resolved = Self::resolve_to_file(&self.root, path)?;
93
94        let resolved = resolved
95            .canonicalize()
96            .map_err(|_err| Error::NotFound(path.to_string()))?;
97        if !resolved.starts_with(&self.root) {
98            return Err(Error::PathTraversal {
99                attempted: path.to_string(),
100                boundary: self.root.to_string_lossy().to_string(),
101            });
102        }
103
104        Ok(resolved)
105    }
106}
107
108#[cfg(test)]
109#[allow(clippy::unwrap_used)]
110mod tests {
111    use super::*;
112    use std::fs::write;
113    use tempfile::TempDir;
114
115    fn setup_test_dir() -> TempDir {
116        let dir = TempDir::new().unwrap();
117        write(dir.path().join("README.md"), "# Root README").unwrap();
118        write(dir.path().join("file.md"), "# File").unwrap();
119        write(dir.path().join("no_readme.md"), "# No Readme File").unwrap();
120        write(dir.path().join("index.html"), "<h1>index</h1>").unwrap();
121        std::fs::create_dir(dir.path().join("subdir")).unwrap();
122        write(dir.path().join("subdir/README.md"), "# Subdir README").unwrap();
123        std::fs::create_dir(dir.path().join("no_readme")).unwrap();
124        dir
125    }
126
127    #[tokio::test]
128    async fn test_resolve_root_readme() {
129        let dir = setup_test_dir();
130        let resolver = LocalContentResolver::new(dir.path()).unwrap();
131
132        let content = resolver.resolve("").await.unwrap();
133        assert!(content.contains("# Root README"));
134    }
135
136    #[tokio::test]
137    async fn test_resolve_file() {
138        let dir = setup_test_dir();
139        let resolver = LocalContentResolver::new(dir.path()).unwrap();
140
141        let content = resolver.resolve("file.md").await.unwrap();
142        assert!(content.contains("# File"));
143    }
144
145    #[tokio::test]
146    async fn test_resolve_file_without_extension() {
147        let dir = setup_test_dir();
148        let resolver = LocalContentResolver::new(dir.path()).unwrap();
149
150        let content = resolver.resolve("file").await.unwrap();
151        assert!(content.contains("# File"));
152    }
153
154    #[tokio::test]
155    async fn test_resolve_subdir_readme() {
156        let dir = setup_test_dir();
157        let resolver = LocalContentResolver::new(dir.path()).unwrap();
158
159        let content = resolver.resolve("subdir").await.unwrap();
160        assert!(content.contains("# Subdir README"));
161    }
162
163    #[tokio::test]
164    async fn test_resolve_subdir_without_readme_falls_back_to_sibling_markdown() {
165        let dir = setup_test_dir();
166        let resolver = LocalContentResolver::new(dir.path()).unwrap();
167
168        let content = resolver.resolve("no_readme").await.unwrap();
169        assert!(content.contains("# No Readme File"));
170    }
171
172    #[tokio::test]
173    async fn test_resolve_index_html_not_found() {
174        let dir = setup_test_dir();
175        let resolver = LocalContentResolver::new(dir.path()).unwrap();
176
177        let result = resolver.resolve("index.html").await;
178        assert!(matches!(result, Err(Error::NotFound(_))));
179    }
180
181    #[tokio::test]
182    async fn test_resolve_not_found() {
183        let dir = setup_test_dir();
184        let resolver = LocalContentResolver::new(dir.path()).unwrap();
185
186        let result = resolver.resolve("nonexistent").await;
187        assert!(matches!(result, Err(Error::NotFound(_))));
188    }
189
190    #[tokio::test]
191    async fn test_resolve_path_traversal() {
192        let dir = setup_test_dir();
193        let resolver = LocalContentResolver::new(dir.path()).unwrap();
194
195        let result = resolver.resolve("../../../etc/passwd").await;
196        assert!(matches!(result, Err(Error::PathTraversal { .. })));
197    }
198}