1use serde::Serialize;
9use std::collections::HashMap;
10use std::path::{Component, Path, PathBuf};
11
12#[derive(Debug, thiserror::Error)]
18pub enum ExplorerError {
19 #[error("workspace root is not set")]
21 MissingRoot,
22 #[error("invalid relative path: {0}")]
25 InvalidRelPath(String),
26 #[error("path escapes workspace root")]
28 PathEscapesRoot,
29 #[error("not a directory: {0}")]
31 NotADirectory(String),
32 #[error(transparent)]
34 Io(#[from] std::io::Error),
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ExplorerEntryKind {
45 File,
47 Dir,
49}
50
51#[derive(Debug, Clone, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub struct ExplorerEntry {
55 pub name: String,
57 pub rel_path: String,
59 pub kind: ExplorerEntryKind,
61 pub is_symlink: bool,
63}
64
65#[derive(Debug, Clone, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub struct ExplorerListDirResponse {
69 pub root: String,
71 pub dir_rel: String,
73 pub entries: Vec<ExplorerEntry>,
75 pub truncated: bool,
77}
78
79#[derive(Debug, Clone, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub struct ExplorerRootResponse {
83 pub root: String,
85 pub is_git_repo: bool,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ExplorerGitChangeKind {
93 Added,
95 Modified,
97 Deleted,
99 Renamed,
101 Copied,
103 Untracked,
105 Unknown,
107}
108
109#[derive(Debug, Clone, Default, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub struct ExplorerGitPathStatus {
113 pub staged: Option<ExplorerGitChangeKind>,
115 pub unstaged: Option<ExplorerGitChangeKind>,
117 pub untracked: bool,
119}
120
121#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub struct ExplorerGitStatusResponse {
125 pub root: String,
127 pub is_git_repo: bool,
129 pub paths: HashMap<String, ExplorerGitPathStatus>,
131 pub error: Option<String>,
133}
134
135pub fn ensure_safe_rel_path(rel: &str) -> Result<PathBuf, ExplorerError> {
144 let rel = rel.trim();
145 if rel.is_empty() {
146 return Ok(PathBuf::new());
147 }
148
149 let p = Path::new(rel);
150 if p.is_absolute() {
151 return Err(ExplorerError::InvalidRelPath(rel.to_string()));
152 }
153
154 for c in p.components() {
155 match c {
156 Component::CurDir => {}
157 Component::Normal(_) => {}
158 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
159 return Err(ExplorerError::InvalidRelPath(rel.to_string()));
160 }
161 }
162 }
163
164 Ok(p.to_path_buf())
165}
166
167fn path_to_slash_string(p: &Path) -> String {
170 p.components()
171 .filter_map(|c| match c {
172 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
173 _ => None,
174 })
175 .collect::<Vec<_>>()
176 .join("/")
177}
178
179pub fn normalize_git_change_path(path: &Path) -> Option<String> {
185 let raw = path.to_string_lossy();
186 let raw = raw.trim();
187 if raw.is_empty() {
188 return None;
189 }
190
191 let raw = raw.trim_matches('"');
192 let raw = match raw.rsplit_once(" -> ") {
193 Some((_, new)) => new.trim(),
194 None => raw,
195 };
196
197 let safe = ensure_safe_rel_path(raw).ok()?;
198 Some(path_to_slash_string(&safe))
199}
200
201pub fn canonical_root(root: &Path) -> Result<PathBuf, ExplorerError> {
203 Ok(std::fs::canonicalize(root)?)
204}
205
206pub fn resolve_under_root(root: &Path, rel: &str) -> Result<PathBuf, ExplorerError> {
209 let rel = ensure_safe_rel_path(rel)?;
210 let root_canon = canonical_root(root)?;
211 let joined = root_canon.join(rel);
212 let target = std::fs::canonicalize(&joined)?;
213 if !target.starts_with(&root_canon) {
214 return Err(ExplorerError::PathEscapesRoot);
215 }
216 Ok(target)
217}
218
219pub fn list_dir(
226 root: &Path,
227 dir_rel: &str,
228 max_entries: usize,
229) -> Result<(Vec<ExplorerEntry>, bool), ExplorerError> {
230 let dir_rel = dir_rel.trim();
231
232 let root_canon = canonical_root(root)?;
233 let dir_path = if dir_rel.is_empty() {
234 root_canon.clone()
235 } else {
236 resolve_under_root(&root_canon, dir_rel)?
237 };
238
239 if !dir_path.is_dir() {
240 return Err(ExplorerError::NotADirectory(dir_path.display().to_string()));
241 }
242
243 let mut entries = Vec::new();
244 let mut truncated = false;
245
246 for (i, e) in std::fs::read_dir(&dir_path)?.enumerate() {
247 if i >= max_entries {
248 truncated = true;
249 break;
250 }
251 let e = e?;
252 let name = e.file_name().to_string_lossy().to_string();
253 if name.is_empty() {
254 continue;
255 }
256
257 let ft = e.file_type()?;
258 let is_symlink = ft.is_symlink();
259
260 let kind = if ft.is_symlink() {
262 match std::fs::canonicalize(e.path()) {
263 Ok(target) if target.starts_with(&root_canon) => {
264 if target.is_dir() {
265 ExplorerEntryKind::Dir
266 } else {
267 ExplorerEntryKind::File
268 }
269 }
270 Ok(_) => {
271 continue;
273 }
274 Err(_) => ExplorerEntryKind::File,
275 }
276 } else if ft.is_dir() {
277 ExplorerEntryKind::Dir
278 } else {
279 ExplorerEntryKind::File
280 };
281
282 let rel_path = if dir_rel.is_empty() {
283 name.clone()
284 } else {
285 path_to_slash_string(Path::new(dir_rel).join(&name).as_path())
286 };
287
288 entries.push(ExplorerEntry {
289 name,
290 rel_path,
291 kind,
292 is_symlink,
293 });
294 }
295
296 entries.sort_by(|a, b| {
297 let a_dir = a.kind == ExplorerEntryKind::Dir;
298 let b_dir = b.kind == ExplorerEntryKind::Dir;
299 match (a_dir, b_dir) {
300 (true, false) => std::cmp::Ordering::Less,
301 (false, true) => std::cmp::Ordering::Greater,
302 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
303 }
304 });
305
306 Ok((entries, truncated))
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn rejects_parent_dir_traversal() {
319 assert!(ensure_safe_rel_path("../secret").is_err());
320 assert!(ensure_safe_rel_path("a/../../b").is_err());
321 }
322
323 #[test]
324 fn allows_empty_and_normal_paths() {
325 assert_eq!(ensure_safe_rel_path("").unwrap(), PathBuf::new());
326 assert_eq!(
327 ensure_safe_rel_path("src/lib.rs").unwrap(),
328 PathBuf::from("src/lib.rs")
329 );
330 }
331
332 #[cfg(unix)]
333 #[test]
334 fn drops_symlinks_that_escape_root() {
335 use std::fs;
336 use std::os::unix::fs::symlink;
337
338 let tmp = tempfile::tempdir().unwrap();
339 let root = tmp.path().join("root");
340 let outside = tmp.path().join("outside");
341 fs::create_dir_all(&root).unwrap();
342 fs::create_dir_all(&outside).unwrap();
343
344 symlink(&outside, root.join("escape")).unwrap();
345 fs::write(root.join("ok.txt"), "ok").unwrap();
346
347 let (entries, _) = list_dir(&root, "", 100).unwrap();
348 assert!(entries.iter().any(|e| e.name == "ok.txt"));
349 assert!(!entries.iter().any(|e| e.name == "escape"));
350 }
351
352 #[test]
353 fn normalize_git_change_path_handles_rename_like_strings() {
354 let p = PathBuf::from("old name.rs -> new name.rs");
355 assert_eq!(
356 normalize_git_change_path(&p).as_deref(),
357 Some("new name.rs")
358 );
359
360 let p = PathBuf::from("\"old.rs -> new.rs\"");
361 assert_eq!(normalize_git_change_path(&p).as_deref(), Some("new.rs"));
362 }
363}