From e84deb79ca23950f80edc55c49f023180465c2de Mon Sep 17 00:00:00 2001 From: Franz Hemmer Date: Fri, 6 Nov 2026 12:16:55 -0500 Subject: [PATCH 1/2] fix: refuse relative and re-listed folders in Link checkout (#87) Link checkout saved the typed folder as is and treated it as listed only when it was the same PathBuf. A relative folder then resolved against wherever the dashboard started, and a `..` or case-only spelling of a listed checkout added a second row. Refuse a folder that isn't a full path before anything is read, say when it doesn't exist, and compare the resolved folder with each full-path entry, so another spelling of a listed checkout writes nothing. The typed path is still what gets saved. Co-Authored-By: Claude Opus 5.5 --- crates/dashboard-core/src/projects.rs | 28 +++++++- crates/dashboard-core/tests/projects.rs | 86 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/crates/dashboard-core/src/projects.rs b/crates/dashboard-core/src/projects.rs index c4259b4..a7475a0 100644 --- a/crates/dashboard-core/src/projects.rs +++ b/crates/dashboard-core/src/projects.rs @@ -312,6 +312,10 @@ pub fn error_field(error: &WriteError) -> Option<&'static str> { /// Why a checkout couldn't be linked to a project. #[derive(Debug)] pub enum LinkError { + /// The folder isn't a full path. A relative one would resolve against wherever the dashboard was started (#87). + Relative, + /// The folder doesn't exist or can't be opened. + Folder(std::io::Error), /// The folder's origin is another repository, or none the dashboard can read. Mismatch { found: Option }, /// `config.toml` couldn't be read or written. @@ -321,6 +325,11 @@ pub enum LinkError { impl std::fmt::Display for LinkError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::Relative => f.write_str("Type the checkout's full path, not a relative folder; nothing was written."), + Self::Folder(error) if error.kind() == std::io::ErrorKind::NotFound => { + f.write_str("That folder doesn't exist; nothing was written.") + } + Self::Folder(error) => write!(f, "Can't open that folder ({error}); nothing was written."), Self::Mismatch { found: Some(found) } => { write!(f, "That folder's origin is {found}; nothing was written.") } @@ -334,13 +343,28 @@ impl std::fmt::Display for LinkError { } /// Adds `checkout` to `repositories` in the `config.toml` in `config_dir`, after checking its origin is `repo` with -/// the same identity rules the dashboard reads status with, and keeping the file's comments. A checkout already -/// listed is left as it is. +/// the same identity rules the dashboard reads status with, and keeping the file's comments. +/// +/// The folder must be a full path; it is saved as typed (#87). A checkout already listed under any spelling of the +/// same folder (`..`, another letter case where the file system ignores case, a link) is left as it is and its listed +/// path returned, so it never gets a second row. pub fn link_checkout(config_dir: &Path, checkout: &Path, repo: &str) -> Result { + if !checkout.is_absolute() { + return Err(LinkError::Relative); + } + let real = std::fs::canonicalize(checkout).map_err(LinkError::Folder)?; let found = crate::pmd::checkout_identity(checkout).ok(); if !found.as_deref().is_some_and(|found| found.eq_ignore_ascii_case(repo)) { return Err(LinkError::Mismatch { found }); } + let listed = Config::load(config_dir).map_err(LinkError::Config)?.repositories; + // Only full paths are compared on disk: a relative entry would resolve against the working folder. + if let Some(known) = listed + .into_iter() + .find(|known| known.is_absolute() && std::fs::canonicalize(known).is_ok_and(|known| known == real)) + { + return Ok(known); + } Config::add_repository(config_dir, checkout).map_err(LinkError::Config)?; Ok(checkout.to_path_buf()) } diff --git a/crates/dashboard-core/tests/projects.rs b/crates/dashboard-core/tests/projects.rs index a60b3f8..638f24c 100644 --- a/crates/dashboard-core/tests/projects.rs +++ b/crates/dashboard-core/tests/projects.rs @@ -437,3 +437,89 @@ fn a_checkout_of_another_repo_or_none_is_not_linked_and_nothing_is_written() { )); assert_eq!(read(&dir), "repositories = []\n"); } + +#[test] +fn a_relative_or_missing_folder_is_not_linked_and_nothing_is_written() { + let folder = Folder::new(); + folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git")); + let dir = folder.config("repositories = []\n"); + // Refused before anything is read, so the result can't depend on the working folder (#87). + let mut relative = vec![PathBuf::from("tickdown"), PathBuf::from("./tickdown")]; + if cfg!(windows) { + relative.extend([PathBuf::from(r"\tickdown"), PathBuf::from("C:tickdown")]); + } + for path in relative { + match projects::link_checkout(&dir, &path, "hemsoft-dev/tickdown") { + Err(error @ LinkError::Relative) => assert_eq!( + error.to_string(), + "Type the checkout's full path, not a relative folder; nothing was written." + ), + other => panic!("{}: {other:?}", path.display()), + } + } + match projects::link_checkout(&dir, &folder.0.join("missing"), "hemsoft-dev/tickdown") { + Err(error @ LinkError::Folder(_)) => { + assert_eq!(error.to_string(), "That folder doesn't exist; nothing was written.") + } + other => panic!("{other:?}"), + } + assert_eq!(read(&dir), "repositories = []\n"); +} + +#[test] +fn another_spelling_of_a_listed_checkout_adds_no_second_row() { + let folder = Folder::new(); + let tickdown = folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git")); + std::fs::create_dir_all(folder.0.join("sub")).unwrap(); + let text = format!( + "# My dashboard\nrepositories = [\n {:?}, # the widget\n]\n", + tickdown.to_string_lossy() + ); + let dir = folder.config(&text); + let separator = std::path::MAIN_SEPARATOR; + let mut spellings = vec![ + tickdown.clone(), + PathBuf::from(format!("{}{separator}", tickdown.display())), + folder.0.join("sub").join("..").join("tickdown"), + ]; + if cfg!(windows) { + // NTFS ignores letter case. + spellings.push(PathBuf::from(tickdown.to_string_lossy().to_uppercase())); + } + #[cfg(unix)] + { + let link = folder.0.join("link"); + std::os::unix::fs::symlink(&tickdown, &link).unwrap(); + spellings.push(link); + } + for spelling in spellings { + let linked = projects::link_checkout(&dir, &spelling, "hemsoft-dev/tickdown"); + assert_eq!(linked.unwrap(), tickdown, "{}", spelling.display()); + assert_eq!(read(&dir), text, "{}", spelling.display()); + } + let config = Config::parse(&read(&dir), &dir.join("config.toml")).unwrap(); + assert_eq!(config.repositories, [tickdown]); +} + +#[test] +fn a_new_checkout_is_saved_as_typed_and_found_again_under_another_spelling() { + let folder = Folder::new(); + let tickdown = folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git")); + std::fs::create_dir_all(folder.0.join("sub")).unwrap(); + let dir = folder.config("repositories = []\n"); + let typed = folder.0.join("sub").join("..").join("tickdown"); + assert_eq!( + projects::link_checkout(&dir, &typed, "hemsoft-dev/tickdown").unwrap(), + typed + ); + let after = read(&dir); + // The comparison's resolved path never reaches the file: no `\\?\` prefix on Windows. + assert!(!after.contains(r"\\?\"), "{after}"); + let config = Config::parse(&after, &dir.join("config.toml")).unwrap(); + assert_eq!(config.repositories, std::slice::from_ref(&typed)); + assert_eq!( + projects::link_checkout(&dir, &tickdown, "hemsoft-dev/tickdown").unwrap(), + typed + ); + assert_eq!(read(&dir), after); +} From c7d344790c6c96b223fe46f694055990c155ab64 Mon Sep 17 00:00:00 2001 From: Franz Hemmer Date: Fri, 6 Nov 2026 12:16:57 -0500 Subject: [PATCH 2/2] test: capture Link checkout's relative, missing and listed states (#87) Co-Authored-By: Claude Opus 5.5 --- crates/dashboard-app/src/captures.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/dashboard-app/src/captures.rs b/crates/dashboard-app/src/captures.rs index c1d844a..ee4f8a8 100644 --- a/crates/dashboard-app/src/captures.rs +++ b/crates/dashboard-app/src/captures.rs @@ -621,6 +621,27 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) { key(cx, tall, &["ctrl-enter"]); save(cx, tall, out, &name("risk-conflict")); + // #87: a relative folder is refused and a missing one says so. A checkout listed since the dialog opened, typed + // with `..`, is found under its listed path and gets no second row. + let paths = Folder::new(); + let (link, link_manage, _) = open_dialog(cx, &["read", "write:projects"], &paths); + click(cx, link, "project-row-2"); + let press_link = |cx: &mut HeadlessAppContext, typed: &Path, state: &str| { + set(cx, link, &link_manage, "folder", &typed.to_string_lossy()); + cx.update(|cx| link_manage.update(cx, |m, cx| m.link_checkout(cx))); + settle(cx); + save(cx, link, out, &name(state)); + }; + press_link(cx, Path::new("hs-buddy"), "link-relative"); + press_link(cx, &paths.0.join("missing"), "link-missing"); + let listed = paths.checkout("hs-buddy", "git@github.com:hemsoft-dev/hs-buddy.git"); + dashboard_core::config::Config::add_repository(&paths.config_dir(), &listed).unwrap(); + std::fs::create_dir_all(paths.0.join("sub")).unwrap(); + let config = paths.config_dir().join("config.toml"); + let before = std::fs::read(&config).unwrap(); + press_link(cx, &paths.0.join("sub").join("..").join("hs-buddy"), "link-listed"); + assert_eq!(std::fs::read(&config).unwrap(), before, "no second row"); + // Frames for the recording, at 100% only: keyboard alone, adding a project and setting its priority. if !suffix.is_empty() { return;