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:
@@ -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
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user