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