//! Host-side file manager: enumerate drives/roots and directory contents, and //! the chunked read/write used by download/upload. Items are reported in the //! viewer's wire shape `{n:name, t:"d"|"f", s:size, m:mtime}`. use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; /// Top-level roots: every present drive on Windows (`C:\`, `D:\`, …), plus the /// user's home, so the viewer's file panel has somewhere to start. pub fn roots() -> Vec { let mut items = Vec::new(); #[cfg(windows)] { for c in b'A'..=b'Z' { let p = format!("{}:\\", c as char); if Path::new(&p).exists() { items.push(json!({ "n": p, "t": "d", "s": 0, "m": 0 })); } } } #[cfg(not(windows))] { items.push(json!({ "n": "/", "t": "d", "s": 0, "m": 0 })); } items } /// List a directory. Entries are sorted folders-first then by name; hidden /// handling is left to the OS (we surface everything `read_dir` returns). pub fn list_dir(path: &str) -> std::io::Result> { let mut items = Vec::new(); for entry in std::fs::read_dir(path)? { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let name = entry.file_name().to_string_lossy().to_string(); let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; let is_dir = meta.is_dir(); let size = if is_dir { 0 } else { meta.len() }; let mtime = meta .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); items.push(json!({ "n": name, "t": if is_dir { "d" } else { "f" }, "s": size, "m": mtime, })); } items.sort_by(|a, b| { let ad = a.get("t").and_then(|x| x.as_str()) == Some("d"); let bd = b.get("t").and_then(|x| x.as_str()) == Some("d"); bd.cmp(&ad).then_with(|| { a.get("n") .and_then(|x| x.as_str()) .unwrap_or("") .to_ascii_lowercase() .cmp(&b.get("n").and_then(|x| x.as_str()).unwrap_or("").to_ascii_lowercase()) }) }); Ok(items) } /// Recursively walk `root` and return every FILE under it as /// `{ "rel": "", "s": }`, so a viewer can /// recreate the tree locally and download each entry. Directories are implied /// by the relative paths (the viewer mkdir -p's each parent). Bounded to avoid /// a runaway walk: at most `MAX_ENTRIES` files and `MAX_DEPTH` deep; symlinks /// are not followed. Returns (entries, truncated). pub fn walk_dir(root: &str) -> std::io::Result<(Vec, bool)> { const MAX_ENTRIES: usize = 50_000; const MAX_DEPTH: usize = 40; let root_path = PathBuf::from(root); let mut out = Vec::new(); let mut truncated = false; // Iterative DFS: (dir, depth). rel paths are built from `root`. let mut stack = vec![(root_path.clone(), 0usize)]; while let Some((dir, depth)) = stack.pop() { if depth > MAX_DEPTH { truncated = true; continue; } let rd = match std::fs::read_dir(&dir) { Ok(r) => r, Err(_) => continue, // unreadable dir -> skip, keep going }; for entry in rd { let entry = match entry { Ok(e) => e, Err(_) => continue, }; // symlink_metadata: do NOT follow symlinks (avoid cycles / escapes). let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; let path = entry.path(); if meta.file_type().is_symlink() { continue; } if meta.is_dir() { stack.push((path, depth + 1)); } else if meta.is_file() { if out.len() >= MAX_ENTRIES { truncated = true; break; } // Relative path with forward slashes (the viewer joins it under // the chosen local folder). let rel = path .strip_prefix(&root_path) .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); out.push(json!({ "rel": rel, "s": meta.len() })); } } if out.len() >= MAX_ENTRIES { truncated = true; break; } } out.sort_by(|a, b| { a.get("rel").and_then(|x| x.as_str()).unwrap_or("") .cmp(b.get("rel").and_then(|x| x.as_str()).unwrap_or("")) }); Ok((out, truncated)) } /// Where an in-progress upload is written before the atomic rename into place. pub fn part_path(final_path: &str) -> PathBuf { PathBuf::from(format!("{final_path}.netdesk.part")) } /// Resolve an upload destination: a relative/empty dir drops onto the Desktop /// (TeamViewer-style default), mirroring the existing host. pub fn resolve_upload_path(path: &str) -> PathBuf { let p = Path::new(path); if p.is_absolute() { return p.to_path_buf(); } // Relative: drop on the Desktop. let home = std::env::var("USERPROFILE") .or_else(|_| std::env::var("HOME")) .unwrap_or_else(|_| ".".into()); Path::new(&home).join("Desktop").join(path) } #[cfg(test)] mod tests { use super::*; #[test] fn walk_dir_recurses_and_relativizes() { // Build a small temp tree: root/{a.txt, sub/b.txt, sub/deep/c.txt}. let base = std::env::temp_dir().join(format!("nd_walk_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("sub").join("deep")).unwrap(); std::fs::write(base.join("a.txt"), b"AA").unwrap(); std::fs::write(base.join("sub").join("b.txt"), b"BBBB").unwrap(); std::fs::write(base.join("sub").join("deep").join("c.txt"), b"CCCCCC").unwrap(); let (entries, truncated) = walk_dir(&base.to_string_lossy()).unwrap(); assert!(!truncated); let rels: Vec = entries .iter() .map(|e| e.get("rel").and_then(|x| x.as_str()).unwrap().to_string()) .collect(); assert_eq!(rels, vec!["a.txt", "sub/b.txt", "sub/deep/c.txt"]); // sorted, forward slashes // sizes carried through let size = |rel: &str| { entries.iter().find(|e| e.get("rel").and_then(|x| x.as_str()) == Some(rel)) .and_then(|e| e.get("s").and_then(|x| x.as_u64())).unwrap() }; assert_eq!(size("a.txt"), 2); assert_eq!(size("sub/b.txt"), 4); assert_eq!(size("sub/deep/c.txt"), 6); let _ = std::fs::remove_dir_all(&base); } }