NetDesk 1.5.0 — source release
This commit is contained in:
commit
21dea33089
227 files changed
+80714
No files matched your search
@@ -0,0 +1,188 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user