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("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}