Files
netdesk/native/host/src/files.rs
T

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);
}
}