statespace_server/
content.rs1use 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 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}