189 lines
7.0 KiB
Rust
189 lines
7.0 KiB
Rust
//! 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<Value> {
|
|
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<Vec<Value>> {
|
|
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": "<forward-slash relative path>", "s": <size> }`, 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<Value>, 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<String> = 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);
|
|
}
|
|
}
|