diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index b24246592..23c9c06e6 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -256,10 +256,11 @@ pub fn specs() -> Vec { "Rename Folder", [], None, - "{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}", + "{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken); refused for the home folder and its Pictures, Desktop and Downloads → {path, relinked}", always, |s, p| { let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string(); + refuse_protected_place("folder.rename", &from)?; let name = str_param(p, "name") .map(str::trim) .filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..") @@ -274,10 +275,11 @@ pub fn specs() -> Vec { "Move Folder", [], None, - "{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}", + "{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken); refused for the home folder and its Pictures, Desktop and Downloads → {path, relinked}", always, |s, p| { let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string(); + refuse_protected_place("folder.move", &from)?; let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?; let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?; let to = Path::new(into).join(name).to_string_lossy().to_string(); @@ -550,3 +552,193 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: follow_folder(s, from, to); Ok(n) } + +/// Whether `path` is the user's home folder or one of its standard places (Pictures, Desktop, +/// Downloads): the computer's own folders, which renaming or moving would break for every other +/// program. `home` is passed in so the check can be tested; the commands read the real one. +pub(crate) fn is_protected_place(path: &Path, home: &Path) -> bool { + use lightcraft_catalog::query::folder_key; + let home = home.to_string_lossy(); + if home.is_empty() { + return false; + } + // compared without case where the usual volumes ignore it (macOS, Windows): there `pictures` is `Pictures` + let fold = cfg!(any(target_os = "macos", windows)); + let key = |p: &str| if fold { folder_key(p).to_lowercase() } else { folder_key(p) }; + let path = key(&path.to_string_lossy()); + std::iter::once(home.to_string()) + .chain(["Pictures", "Desktop", "Downloads"].map(|sub| Path::new(&*home).join(sub).to_string_lossy().to_string())) + .any(|p| key(&p) == path) +} + +/// Refuses `from` when renaming or moving it would take the home folder or a standard place of it. +fn refuse_protected_place(command: &str, from: &str) -> Result<()> { + refuse_protected_place_in(command, from, crate::library::home_folder().as_deref()) +} + +/// What `from` names once the file system has read it: made absolute against the working folder +/// (the commands act on it that way) and with its parent's links followed, so a spelling through a +/// linked folder is the same place. The last name stays as written (a link that merely points at +/// Pictures is its own folder: renaming it renames the link), except that a name which is not a +/// link is also read in full, which turns a Windows short name (`DOWNLO~1`) into the long one. +fn named_places(from: &str) -> Vec { + let spelled = std::path::absolute(from).unwrap_or_else(|_| from.into()); + let followed = spelled.parent().and_then(|p| p.canonicalize().ok()).zip(spelled.file_name()).map(|(p, name)| p.join(name)); + let is_link = std::fs::symlink_metadata(&spelled).is_ok_and(|m| m.file_type().is_symlink()); + let read = if is_link { None } else { spelled.canonicalize().ok() }; + std::iter::once(spelled).chain(followed).chain(read).collect() +} + +/// The home folder and its standard places. +fn standard_places(home: &Path) -> Vec { + std::iter::once(home.to_path_buf()).chain(["Pictures", "Desktop", "Downloads"].map(|sub| home.join(sub))).collect() +} + +/// Whether `from` is, on disk, one of `places`, however it is spelled (a firmlink or bind mount +/// reaches the same folder under another path). A link to a place is not the place, but a place +/// that is itself a link stands for the folder it points to (the places are read through their +/// links), since renaming that folder would break the place. +#[cfg(unix)] +fn is_one_of(from: &str, places: &[std::path::PathBuf]) -> bool { + use std::os::unix::fs::MetadataExt; + let Ok(mine) = std::fs::symlink_metadata(std::path::absolute(from).unwrap_or_else(|_| from.into())) else { return false }; + mine.is_dir() && places.iter().filter_map(|p| std::fs::metadata(p).ok()).any(|m| (m.dev(), m.ino()) == (mine.dev(), mine.ino())) +} + +#[cfg(not(unix))] +fn is_one_of(_from: &str, _places: &[std::path::PathBuf]) -> bool { + false +} + +fn refuse_protected_place_in(command: &str, from: &str, home: Option<&Path>) -> Result<()> { + let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) else { return Ok(()) }; + let homes: Vec = std::iter::once(home.to_path_buf()).chain(home.canonicalize().ok()).collect(); + let spelled = named_places(from).iter().any(|place| homes.iter().any(|h| is_protected_place(place, h))); + if spelled || is_one_of(from, &homes.iter().flat_map(|h| standard_places(h)).collect::>()) { + return Err(bad( + command, + format!( + "{from} is the home folder or one of its standard places (Pictures, Desktop, Downloads); rename or move it in the system's file manager instead" + ), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{is_protected_place, refuse_protected_place_in}; + use std::path::Path; + + #[test] + fn the_home_folder_and_its_standard_places_are_protected() { + let home = Path::new("/home/example"); + for path in [ + "/home/example", + "/home/example/", + "/home/example/Pictures", + "/home/example/Pictures/", + "/home/example/Desktop", + "/home/example/Downloads", + ] { + assert!(is_protected_place(Path::new(path), home), "{path}"); + } + for path in ["/home/example/Pictures/Trip", "/home/example/Documents", "/home/other", "/home", "/home/example2", "Pictures", ""] { + assert!(!is_protected_place(Path::new(path), home), "{path}"); + } + // no home known: nothing is protected, and no relative path is mistaken for a place + assert!(!is_protected_place(Path::new("Pictures"), Path::new(""))); + assert!(!is_protected_place(Path::new(""), Path::new(""))); + // case counts only where the volume ignores it + assert_eq!(is_protected_place(Path::new("/home/example/pictures"), home), cfg!(any(target_os = "macos", windows))); + } + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lightcraft-protected-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("home/Pictures")).expect("scratch home"); + dir + } + + #[test] + fn a_protected_place_is_refused_by_name_with_the_reason() { + let dir = scratch("named"); + let home = dir.join("home"); + for from in [home.clone(), home.join("Pictures"), home.join("Pictures").join(".")] { + let e = refuse_protected_place_in("folder.rename", &from.to_string_lossy(), Some(&home)).expect_err("refused").to_string(); + assert!(e.contains("home folder") && e.contains("file manager"), "{e}"); + } + assert!(refuse_protected_place_in("folder.move", &home.join("Pictures").join("Trip").to_string_lossy(), Some(&home)).is_ok()); + assert!(refuse_protected_place_in("folder.rename", &home.to_string_lossy(), None).is_ok(), "no home known"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_relative_path_to_a_protected_place_is_refused() { + let dir = scratch("relative"); + // the home folder is spelled through a link, so only the spelling as given can match it + #[cfg(unix)] + let home = { + std::os::unix::fs::symlink(dir.join("home"), dir.join("alias")).expect("link"); + dir.join("alias") + }; + #[cfg(not(unix))] + let home = dir.join("home"); + // the commands act on the process's working folder, so a relative path names the real place + let cwd = std::env::current_dir().expect("working folder"); + let relative = pathdiff(&home.join("Pictures"), &cwd); + if !Path::new(&relative).is_absolute() { + // what the commands will see: the working folder's spelling of the same place + assert!(super::named_places(&relative).iter().any(|p| is_protected_place(p, &home)), "{relative}"); + assert!(refuse_protected_place_in("folder.rename", &relative, Some(&home)).is_err(), "{relative}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// `target` as a path relative to `from` (both absolute), with `..` steps. + fn pathdiff(target: &Path, from: &Path) -> String { + let (t, f): (Vec<_>, Vec<_>) = (target.components().collect(), from.components().collect()); + let common = t.iter().zip(&f).take_while(|(a, b)| a == b).count(); + let mut out = std::path::PathBuf::new(); + f[common..].iter().for_each(|_| out.push("..")); + t[common..].iter().for_each(|c| out.push(c)); + out.to_string_lossy().to_string() + } + + #[cfg(target_os = "macos")] + #[test] + fn a_protected_place_spelled_through_a_firmlink_is_refused() { + let dir = scratch("firmlink"); + let home = dir.join("home"); + // the data volume is also reachable under /System/Volumes/Data: the same folders, spelled otherwise + let real = home.canonicalize().expect("home"); + let spelled = Path::new("/System/Volumes/Data").join(real.strip_prefix("/").expect("absolute")); + if spelled.is_dir() { + for from in [spelled.clone(), spelled.join("Pictures")] { + assert!(refuse_protected_place_in("folder.rename", &from.to_string_lossy(), Some(&home)).is_err(), "{}", from.display()); + } + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn a_protected_place_reached_through_a_linked_parent_is_refused() { + let dir = scratch("linked"); + let home = dir.join("home"); + let alias = dir.join("alias"); + std::os::unix::fs::symlink(&home, &alias).expect("link"); + // the home folder is spelled one way, the path another, both naming the same folder + let through_the_link = alias.join("Pictures"); + assert!(refuse_protected_place_in("folder.rename", &through_the_link.to_string_lossy(), Some(&home)).is_err()); + assert!( + refuse_protected_place_in("folder.rename", &alias.join("Pictures").to_string_lossy(), Some(&alias)).is_err(), + "home spelled through the link" + ); + // a link that merely points at Pictures is its own folder: renaming it renames the link + let shortcut = dir.join("shortcut"); + std::os::unix::fs::symlink(home.join("Pictures"), &shortcut).expect("link"); + assert!(refuse_protected_place_in("folder.rename", &shortcut.to_string_lossy(), Some(&home)).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 459eba713..2021951ba 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -68,6 +68,37 @@ pub fn default_dir() -> Option { Some(PathBuf::from(home).join("Pictures").join(DEFAULT_NAME)) } +/// The user's home folder (`%USERPROFILE%` on Windows, `$HOME` elsewhere); `None` when the host has +/// none or it is empty. The one place the home path is read for Local's built-in places, in the +/// engine's rename/move guard and in the sidebar. +pub fn home_folder() -> Option { + home_folder_from(cfg!(windows), |name| std::env::var_os(name)) +} + +fn home_folder_from(windows: bool, var: impl Fn(&str) -> Option) -> Option { + var(if windows { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(PathBuf::from) +} + +#[cfg(test)] +mod home_tests { + use super::home_folder_from; + use std::ffi::OsString; + + #[test] + fn windows_reads_userprofile_and_the_rest_read_home() { + let env = |name: &str| match name { + "HOME" => Some(OsString::from("/home/example")), + "USERPROFILE" => Some(OsString::from("C:\\Users\\example")), + _ => None, + }; + assert_eq!(home_folder_from(true, env).as_deref(), Some(std::path::Path::new("C:\\Users\\example"))); + assert_eq!(home_folder_from(false, env).as_deref(), Some(std::path::Path::new("/home/example"))); + // only the other platform's variable is set: no home, never a guess from it + assert_eq!(home_folder_from(true, |n| (n == "HOME").then(|| OsString::from("/home/example"))), None); + assert_eq!(home_folder_from(false, |_| Some(OsString::new())), None); + } +} + pub struct Library { /// The library directory, or a descriptive pseudo-path when the library isn't on disk. pub dir: PathBuf, diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 20c1bbce1..15502d2f7 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,5 +1,5 @@ use super::albums::pointer_over_row; -use super::local::{same_folder, subfolders}; +use super::local::{is_builtin_place, same_folder, subfolders}; use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle}; use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2}; @@ -69,22 +69,26 @@ pub(super) fn folder_tree( let _ = app.run("local.hide", json!({"path": path})); ui.close(); } - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = name), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.to_string(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), + // a built-in place is one of the computer's own folders under a translated label (Home is + // the user's whole home): Local offers no Rename or Move for it (the other items stay) + if !(indent == 0.0 && is_builtin_place(app, path)) { + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = name), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } } } } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 1308059c8..5e43ab420 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -18,12 +18,10 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option Option