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