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("API.md"), "# Root API").unwrap();
118        write(dir.path().join("README.md"), "# Root README").unwrap();
119        write(dir.path().join("file.md"), "# File").unwrap();
120        write(dir.path().join("no_readme.md"), "# No Readme File").unwrap();
121        write(dir.path().join("index.html"), "<h1>index</h1>").unwrap();
122        std::fs::create_dir(dir.path().join("subdir")).unwrap();
123        write(dir.path().join("subdir/README.md"), "# Subdir README").unwrap();
124        std::fs::create_dir(dir.path().join("no_readme")).unwrap();
125        dir
126    }
127
128    #[tokio::test]
129    async fn test_resolve_root() {
130        let dir = setup_test_dir();
131        let resolver = LocalContentResolver::new(dir.path()).unwrap();
132
133        let content = resolver.resolve("").await.unwrap();
134        assert!(content.contains("# Root API"));
135    }
136
137    #[tokio::test]
138    async fn test_resolve_file() {
139        let dir = setup_test_dir();
140        let resolver = LocalContentResolver::new(dir.path()).unwrap();
141
142        let content = resolver.resolve("file.md").await.unwrap();
143        assert!(content.contains("# File"));
144    }
145
146    #[tokio::test]
147    async fn test_resolve_file_without_extension() {
148        let dir = setup_test_dir();
149        let resolver = LocalContentResolver::new(dir.path()).unwrap();
150
151        let content = resolver.resolve("file").await.unwrap();
152        assert!(content.contains("# File"));
153    }
154
155    #[tokio::test]
156    async fn test_resolve_subdir_readme() {
157        let dir = setup_test_dir();
158        let resolver = LocalContentResolver::new(dir.path()).unwrap();
159
160        let content = resolver.resolve("subdir").await.unwrap();
161        assert!(content.contains("# Subdir README"));
162    }
163
164    #[tokio::test]
165    async fn test_resolve_subdir_without_readme_falls_back_to_sibling_markdown() {
166        let dir = setup_test_dir();
167        let resolver = LocalContentResolver::new(dir.path()).unwrap();
168
169        let content = resolver.resolve("no_readme").await.unwrap();
170        assert!(content.contains("# No Readme File"));
171    }
172
173    #[tokio::test]
174    async fn test_resolve_non_markdown_file() {
175        let dir = setup_test_dir();
176        let resolver = LocalContentResolver::new(dir.path()).unwrap();
177
178        let content = resolver.resolve("index.html").await.unwrap();
179        assert!(content.contains("<h1>index</h1>"));
180    }
181
182    #[tokio::test]
183    async fn test_resolve_not_found() {
184        let dir = setup_test_dir();
185        let resolver = LocalContentResolver::new(dir.path()).unwrap();
186
187        let result = resolver.resolve("nonexistent").await;
188        assert!(matches!(result, Err(Error::NotFound(_))));
189    }
190
191    #[tokio::test]
192    async fn test_resolve_path_traversal() {
193        let dir = setup_test_dir();
194        let resolver = LocalContentResolver::new(dir.path()).unwrap();
195
196        let result = resolver.resolve("../../../etc/passwd").await;
197        assert!(matches!(result, Err(Error::PathTraversal { .. })));
198    }
199}