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.
This commit is contained in:
2026-10-15 15:26:16 +02:00
parent 5ef5c1ba94
commit bf6ae3e2bb
5 changed files with 181 additions and 21 deletions
+34 -1
View File
@@ -16,7 +16,7 @@ pub struct ChangedFile {
pub deleted: Option<u32>,
}
#[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);
}
}
+111 -5
View File
@@ -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<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
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<String, String> {
// 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<String
cmd.args(&ssh[1..]);
// Never ask for a password: gites has no terminal to ask on.
cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]);
// If the server stops answering, ssh gives up after about 10 seconds instead of
// waiting for ever, also on a connection that is shared.
cmd.args(["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]);
// Later commands to the same server reuse one connection for a while. Windows ssh
// does not support this.
#[cfg(not(windows))]
{
let socket = dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C");
if let Some(dir) = socket_dir(dirs::runtime_dir(), dirs::cache_dir()) {
cmd.args(["-o", "ControlMaster=auto", "-o"]);
cmd.arg(format!("ControlPath={}", socket.display()));
cmd.arg(format!("ControlPath={}", dir.join("gites-%C").display()));
cmd.args(["-o", "ControlPersist=10m"]);
}
// `--` keeps a target from being read as an option.
@@ -293,6 +322,83 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn explains_a_failed_connection_when_ssh_exits_with_255() {
let fake = ["sh", "-c", "echo 'Host key verification failed.' >&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() };
+11 -8
View File
@@ -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<RepoDir>) -> Result<Option<git::RepoData>, String> {
#[tauri::command(async)]
fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result<Option<git::RepoData>, String> {
match dir.get() {
Ok(d) => git::load(&d).map(Some),
Err(_) => Ok(None),
@@ -31,8 +34,8 @@ fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String
}
/// Switches to another repository. The current one stays open if `path` is not a repository.
#[tauri::command]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
#[tauri::command(async)]
fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result<git::RepoData, String> {
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<RepoDir>, path: String) -> Result<git::RepoData,
Ok(data)
}
#[tauri::command]
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
#[tauri::command(async)]
fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
details::load(&dir.get()?, &hash)
}
#[tauri::command]
#[tauri::command(async)]
fn file_diff(
dir: tauri::State<RepoDir>,
dir: tauri::State<'_, RepoDir>,
hash: String,
path: String,
old_path: Option<String>,
+6 -2
View File
@@ -46,11 +46,12 @@ pub fn quote(word: &str) -> String {
}
/// The command for a shell that reads quotes the standard way: `git -C <path>` 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'");
}
}
+19 -5
View File
@@ -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<Vec<OsString>> = 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<Option<Vec<OsString>>> = 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<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
words.push(home.as_os_str().to_owned());
SSH_OVERRIDE.set(Some(words));
}