From bf6ae3e2bb3cf9d8509a1a7d86bf4303c5c31753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:26:16 +0200 Subject: [PATCH] Keep the window responsive and ssh connections safe Commands that run git now run away from the thread that draws the window, so a slow server no longer freezes it. ssh now notices a server that stopped answering in about 10 seconds. The sockets for shared connections go in the runtime folder, or else in a private gites folder in the cache folder, never in /tmp; without either, connections are not shared. A path that is exactly ~ now means the home folder on the server. Tests cover commit details through ssh, the message for a failed connection, and a ~/ path end to end. The stand-in ssh is now set per test, so a test can give it its own home folder. --- src-tauri/src/details.rs | 35 +++++++++++- src-tauri/src/git.rs | 116 ++++++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 19 ++++--- src-tauri/src/location.rs | 8 ++- src-tauri/src/test_ssh.rs | 24 ++++++-- 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs index 06489d3..c5b8677 100644 --- a/src-tauri/src/details.rs +++ b/src-tauri/src/details.rs @@ -16,7 +16,7 @@ pub struct ChangedFile { pub deleted: Option, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, PartialEq)] pub struct CommitDetails { hash: String, author: String, @@ -153,4 +153,37 @@ mod tests { fn parses_an_empty_diff() { assert_eq!(parse_files("", ""), vec![]); } + + #[test] + fn shows_the_same_details_through_ssh() { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let run = |args: &[&str]| { + let out = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + run(&["init", "-q", "-b", "main"]); + std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap(); + run(&["add", "-A"]); + run(&["commit", "-q", "-m", "first"]); + std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap(); + run(&["commit", "-q", "-am", "second\n\nwith a body"]); + let hash = run(&["rev-parse", "HEAD"]); + crate::test_ssh::fake_ssh(); + let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() }; + let local = load(&Location::Local(dir.clone()), &hash).unwrap(); + let through_ssh = load(&remote, &hash).unwrap(); + assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]); + assert_eq!(local, through_ssh); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 47dc064..6d6813d 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -48,12 +48,39 @@ pub fn ssh_error(target: &str, stderr: &str) -> String { message } +/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder, +/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that +/// only the user can open it. `None` when neither gives a folder; then every command opens +/// its own connection. +#[cfg(not(windows))] +fn socket_dir(runtime: Option, cache: Option) -> Option { + if runtime.is_some() { + return runtime; + } + let dir = cache?.join("gites"); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + match builder.create(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {} + Err(_) => return None, + } + // A folder left from before may let others in; only the user may reach the sockets. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?; + } + Some(dir) +} + /// Runs git in the folder `loc` names: here, or on another machine through ssh. pub fn git(loc: &Location, args: &[&str]) -> Result { // Tests swap in a script that acts like ssh. #[cfg(test)] - if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() { - return git_via(fake, loc, args); + if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) { + return git_via(&fake, loc, args); } git_via(&[OsString::from("ssh")], loc, args) } @@ -72,13 +99,15 @@ pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result&2; exit 255", "fake-ssh"].map(OsString::from); + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(&fake, &loc, &["status"]).unwrap_err(); + assert_eq!( + err, + "could not connect to myserver: Host key verification failed.\n\ + Run `ssh myserver` once in a terminal to accept the server's key." + ); + } + + #[test] + fn asks_ssh_to_notice_a_dead_connection() { + // This stand-in prints the words it was given, one per line, and fails. + let fake = ["sh", "-c", "printf '%s\\n' \"$@\" >&2; exit 1", "fake-ssh"].map(OsString::from); + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(&fake, &loc, &["status"]).unwrap_err(); + assert!( + err.contains("-o\nConnectTimeout=10\n-o\nServerAliveInterval=5\n-o\nServerAliveCountMax=2\n"), + "{err}" + ); + } + + #[test] + fn opens_a_path_in_the_home_folder_through_ssh() { + let home = temp_dir("home"); + let dir = home.join("repo"); + std::fs::create_dir_all(&dir).unwrap(); + run_git(&dir, &["init", "-q", "-b", "main"]); + std::fs::write(dir.join("a.txt"), "one\n").unwrap(); + run_git(&dir, &["add", "-A"]); + run_git(&dir, &["commit", "-q", "-m", "first"]); + crate::test_ssh::fake_ssh_with_home(&home); + let data = load(&Location::Ssh { target: "myserver".into(), path: "~/repo".into() }).unwrap(); + assert_eq!(data.commits.len(), 1); + assert_eq!(data.root, format!("myserver:{}", dir.display())); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(not(windows))] + #[test] + fn keeps_sockets_in_the_runtime_folder() { + let runtime = PathBuf::from("/run/user/1000"); + assert_eq!(socket_dir(Some(runtime.clone()), Some(PathBuf::from("/c"))), Some(runtime)); + } + + #[cfg(not(windows))] + #[test] + fn keeps_sockets_in_a_private_cache_folder_when_there_is_no_runtime_folder() { + let cache = temp_dir("cache"); + let got = socket_dir(None, Some(cache.clone())); + assert_eq!(got, Some(cache.join("gites"))); + assert!(cache.join("gites").is_dir()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(cache.join("gites")).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + // A second call finds the folder already there. + assert_eq!(socket_dir(None, Some(cache.clone())), Some(cache.join("gites"))); + let _ = std::fs::remove_dir_all(&cache); + } + + #[cfg(not(windows))] + #[test] + fn shares_no_connection_when_there_is_no_folder_for_sockets() { + assert_eq!(socket_dir(None, None), None); + // The cache folder is a file here, so no folder can be made inside it. + let base = temp_dir("cache-file"); + let file = base.join("file"); + std::fs::write(&file, "").unwrap(); + assert_eq!(socket_dir(None, Some(file)), None); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn reports_a_missing_ssh_program() { let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45cf545..4365116 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,9 +21,12 @@ impl RepoDir { } } +// The commands that run git are marked `async` so Tauri runs them away from the thread that +// draws the window: a command that waits for a slow server must not freeze the window. + /// The open repository, or `None` when there is none yet. -#[tauri::command] -fn load_repo(dir: tauri::State) -> Result, String> { +#[tauri::command(async)] +fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result, String> { match dir.get() { Ok(d) => git::load(&d).map(Some), Err(_) => Ok(None), @@ -31,8 +34,8 @@ fn load_repo(dir: tauri::State) -> Result, String } /// Switches to another repository. The current one stays open if `path` is not a repository. -#[tauri::command] -fn open_repo(dir: tauri::State, path: String) -> Result { +#[tauri::command(async)] +fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let loc = location::parse(&path, &cwd); let data = git::load(&loc)?; @@ -40,14 +43,14 @@ fn open_repo(dir: tauri::State, path: String) -> Result, hash: String) -> Result { +#[tauri::command(async)] +fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result { details::load(&dir.get()?, &hash) } -#[tauri::command] +#[tauri::command(async)] fn file_diff( - dir: tauri::State, + dir: tauri::State<'_, RepoDir>, hash: String, path: String, old_path: Option, diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs index cf2497d..e0d42c5 100644 --- a/src-tauri/src/location.rs +++ b/src-tauri/src/location.rs @@ -46,11 +46,12 @@ pub fn quote(word: &str) -> String { } /// The command for a shell that reads quotes the standard way: `git -C ` followed by -/// `args`. A path starting with `~/` keeps the `~` outside the quotes so the shell still -/// replaces it with the home folder. +/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the +/// shell still replaces it with the home folder. pub fn sh_command(path: &str, args: &[&str]) -> String { let path = match path.strip_prefix("~/") { Some(rest) => format!("~/{}", quote(rest)), + None if path == "~" => "~".to_string(), None => quote(path), }; let mut command = format!("git -C {path}"); @@ -182,5 +183,8 @@ mod tests { "git -C '/srv/my app' 'log' '--format=%H'" ); assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'"); + // Only the home folder of the user logged in is left for the shell; `~other` is not. + assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'"); } } diff --git a/src-tauri/src/test_ssh.rs b/src-tauri/src/test_ssh.rs index 900619a..1a9f2cb 100644 --- a/src-tauri/src/test_ssh.rs +++ b/src-tauri/src/test_ssh.rs @@ -1,10 +1,15 @@ //! A stand-in for ssh, so tests can run the remote code path without a server. +use std::cell::RefCell; use std::ffi::OsString; -use std::sync::OnceLock; +use std::path::Path; -/// The words `git::git` starts instead of `ssh` while tests run: a program, then its arguments. -pub static SSH_OVERRIDE: OnceLock> = OnceLock::new(); +thread_local! { + /// The words `git::git` starts instead of `ssh` in the test running on this thread: a + /// program, then its arguments. Each test runs on its own thread, so one test's stand-in + /// never reaches another test. + pub static SSH_OVERRIDE: RefCell>> = const { RefCell::new(None) }; +} /// The shell script that acts like ssh. It checks the arguments the way a real ssh would /// need them (so a wrong argument list fails a test), skips the options and the target, @@ -21,8 +26,17 @@ shift 2 exec sh -c "$1" "#; -/// Makes `git::git` use the stand-in for the rest of the test run. No file is written: +/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written: /// the script is given to `sh` on its command line. pub fn fake_ssh() { - SSH_OVERRIDE.get_or_init(|| ["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()); + SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec())); +} + +/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real +/// server would run it in the user's home. Only the stand-in's own shell sees the new home. +pub fn fake_ssh_with_home(home: &Path) { + let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}"); + let mut words: Vec = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec(); + words.push(home.as_os_str().to_owned()); + SSH_OVERRIDE.set(Some(words)); }