//! Local disk for the File Manager panels. The UI never gets a generic file API: only listing and //! the few operations below, on absolute paths, never inside the application's data directory //! (vault, logs) and never following a symbolic link when deleting. use super::service::{file_error, safe_entry_name}; use crate::core::error::CoreResult; use chrono::{DateTime, Utc}; use serde_json::{json, Value}; use std::path::{Component, Path, PathBuf}; /// Absolute, without `.` and `..`, outside `protected`. pub fn checked_path(value: &str, protected: &Path) -> CoreResult { let path = PathBuf::from(value); if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) { return Err(file_error("FILE_UNSAFE_PATH", "The local path must be absolute.")); } // A symlink can point into the data directory, so the real path of the nearest existing parent counts too. let protected_real = std::fs::canonicalize(protected).unwrap_or_else(|_| protected.to_path_buf()); let mut existing = path.as_path(); let real = loop { match std::fs::canonicalize(existing) { Ok(real) => break real, Err(_) => match existing.parent() { Some(parent) => existing = parent, None => break path.clone(), }, } }; if path.starts_with(protected) || real.starts_with(&protected_real) { return Err(file_error("FILE_PROTECTED_PATH", "The application's data directory cannot be used here.")); } Ok(path) } fn io_error(error: std::io::Error, path: &Path) -> crate::core::error::CoreError { let code = match error.kind() { std::io::ErrorKind::NotFound => "FILE_NOT_FOUND", std::io::ErrorKind::PermissionDenied => "LOCAL_PERMISSION_DENIED", std::io::ErrorKind::AlreadyExists => "FILE_EXISTS", std::io::ErrorKind::DirectoryNotEmpty => "FILE_DIRECTORY_NOT_EMPTY", _ => "LOCAL_OPERATION_FAILED", }; file_error(code, &format!("{}: {error}", path.display())) } /// Home directory and, on Windows, the drive roots. pub fn places() -> Value { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")); let mut roots: Vec = Vec::new(); if cfg!(windows) { for letter in b'A'..=b'Z' { let root = format!("{}:\\", letter as char); if Path::new(&root).exists() { roots.push(root); } } } else { roots.push("/".into()); } json!({ "home": home.to_string_lossy(), "roots": roots, "separator": std::path::MAIN_SEPARATOR.to_string() }) } pub fn list(value: &str, protected: &Path) -> CoreResult { let directory = checked_path(value, protected)?; let mut entries = Vec::new(); for entry in std::fs::read_dir(&directory).map_err(|e| io_error(e, &directory))? { let Ok(entry) = entry else { continue }; let path = entry.path(); let Ok(meta) = std::fs::symlink_metadata(&path) else { continue }; let link = meta.file_type().is_symlink(); let target = if link { std::fs::metadata(&path).ok() } else { None }; let kind = if link { "symlink" } else if meta.is_dir() { "directory" } else if meta.is_file() { "file" } else { "other" }; entries.push(json!({ "name": entry.file_name().to_string_lossy(), "path": path.to_string_lossy(), "type": kind, "targetType": if !link { Value::Null } else { match &target { Some(t) if t.is_dir() => json!("directory"), Some(_) => json!("file"), None => json!("missing") } }, "size": meta.len(), "modifiedAt": meta.modified().ok().map(|m| DateTime::::from(m).to_rfc3339()) })); } Ok(Value::Array(entries)) } pub fn mkdir(parent: &str, name: &str, protected: &Path) -> CoreResult { let path = checked_path(parent, protected)?.join(safe_entry_name(name)?); std::fs::create_dir(&path).map_err(|e| io_error(e, &path))?; Ok(json!({ "path": path.to_string_lossy() })) } /// Never replaces anything: an existing name is an error. pub fn create_file(parent: &str, name: &str, protected: &Path) -> CoreResult { let path = checked_path(parent, protected)?.join(safe_entry_name(name)?); std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| io_error(e, &path))?; Ok(json!({ "path": path.to_string_lossy() })) } pub fn rename(from: &str, name: &str, protected: &Path) -> CoreResult { let source = checked_path(from, protected)?; let parent = source.parent().ok_or_else(|| file_error("FILE_PROTECTED_PATH", "A root cannot be renamed."))?; let target = parent.join(safe_entry_name(name)?); if std::fs::symlink_metadata(&target).is_ok() { return Err(file_error("FILE_EXISTS", &format!("{} already exists.", target.display()))); } std::fs::rename(&source, &target).map_err(|e| io_error(e, &source))?; Ok(json!({ "path": target.to_string_lossy() })) } /// Roots, the home directory and its ancestors are refused. A link is removed, not what it points to. pub fn remove(paths: &[String], protected: &Path) -> CoreResult { let home = places()["home"].as_str().map(PathBuf::from); let mut checked = Vec::new(); for value in paths { let path = checked_path(value, protected)?; if path.parent().is_none() || home.as_ref().is_some_and(|h| h.starts_with(&path)) || protected.starts_with(&path) { return Err(file_error("FILE_PROTECTED_PATH", &format!("{} cannot be deleted.", path.display()))); } checked.push(path); } for path in &checked { let meta = std::fs::symlink_metadata(path).map_err(|e| io_error(e, path))?; if meta.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }.map_err(|e| io_error(e, path))?; } Ok(json!({ "removed": checked.len() })) } #[cfg(test)] mod tests { use super::*; #[test] fn paths_are_absolute_and_stay_out_of_the_data_directory() { let dir = tempfile::tempdir().unwrap(); let data = dir.path().join("data"); std::fs::create_dir(&data).unwrap(); std::fs::write(data.join("vault"), "secret").unwrap(); assert_eq!(checked_path("relative/file", &data).unwrap_err().code, "FILE_UNSAFE_PATH"); let dotted = format!("{}/x/../data", dir.path().display()); assert_eq!(checked_path(&dotted, &data).unwrap_err().code, "FILE_UNSAFE_PATH"); assert_eq!(checked_path(data.join("vault").to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH"); #[cfg(unix)] { let link = dir.path().join("link"); std::os::unix::fs::symlink(&data, &link).unwrap(); assert_eq!(checked_path(link.join("vault").to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH", "a link into the data directory"); assert_eq!(list(link.to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH"); } } #[test] fn names_cannot_leave_their_folder() { let dir = tempfile::tempdir().unwrap(); let (base, data) = (dir.path().join("work"), dir.path().join("data")); std::fs::create_dir_all(&base).unwrap(); for bad in ["../evil", "a/b", "..", ""] { assert!(mkdir(base.to_str().unwrap(), bad, &data).is_err(), "{bad}"); } mkdir(base.to_str().unwrap(), "ok", &data).unwrap(); for good in ["a.txt", "b.htm", "c.conf", "bez-pripony", ".skryty"] { create_file(base.to_str().unwrap(), good, &data).unwrap(); } assert_eq!(create_file(base.to_str().unwrap(), "a.txt", &data).unwrap_err().code, "FILE_EXISTS", "an existing file stays"); assert!(create_file(base.to_str().unwrap(), "../evil.txt", &data).is_err()); for good in ["a.txt", "b.htm", "c.conf", "bez-pripony", ".skryty"] { std::fs::remove_file(base.join(good)).unwrap(); } std::fs::write(base.join("file"), "x").unwrap(); assert!(rename(base.join("file").to_str().unwrap(), "../evil", &data).is_err()); assert_eq!(rename(base.join("file").to_str().unwrap(), "ok", &data).unwrap_err().code, "FILE_EXISTS"); rename(base.join("file").to_str().unwrap(), "renamed", &data).unwrap(); let names: Vec = list(base.to_str().unwrap(), &data).unwrap().as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect(); assert!(names.contains(&"renamed".to_string()) && names.contains(&"ok".to_string())); } #[cfg(unix)] #[test] fn delete_removes_a_link_not_its_target_and_refuses_home_and_roots() { let dir = tempfile::tempdir().unwrap(); let (kept, doomed, data) = (dir.path().join("kept"), dir.path().join("doomed"), dir.path().join("data")); std::fs::create_dir_all(&kept).unwrap(); std::fs::create_dir_all(&doomed).unwrap(); std::fs::write(kept.join("file"), "x").unwrap(); std::os::unix::fs::symlink(&kept, doomed.join("link")).unwrap(); remove(&[doomed.to_string_lossy().to_string()], &data).unwrap(); assert!(!doomed.exists()); assert!(kept.join("file").exists(), "the link's target survives"); assert_eq!(remove(&["/".to_string()], &data).unwrap_err().code, "FILE_PROTECTED_PATH"); let home = places()["home"].as_str().unwrap().to_string(); assert_eq!(remove(&[home], &data).unwrap_err().code, "FILE_PROTECTED_PATH"); } }