initial ssh tests and async git2 backend implementation

allow certain git backends to temporarily disable IO tests
This commit is contained in:
Josh Junon 2024-02-07 12:55:23 +01:00 committed by GitButler
parent f2c3a571a7
commit b90c9235a3
20 changed files with 1712 additions and 263 deletions

315
Cargo.lock generated
View File

@ -17,6 +17,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.3"
@ -28,6 +38,20 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.3"
@ -368,6 +392,17 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bcrypt-pbkdf"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
dependencies = [
"blowfish",
"pbkdf2 0.12.2",
"sha2",
]
[[package]]
name = "bincode"
version = "1.3.3"
@ -377,6 +412,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit_field"
version = "0.10.2"
@ -410,6 +451,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.4.1"
@ -426,6 +476,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "brotli"
version = "3.3.4"
@ -548,6 +608,15 @@ dependencies = [
"toml 0.7.6",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.0.83"
@ -600,6 +669,17 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chrono"
version = "0.4.33"
@ -862,6 +942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -902,6 +983,15 @@ dependencies = [
"syn 2.0.48",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.0"
@ -916,6 +1006,7 @@ dependencies = [
"platforms",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
@ -977,6 +1068,12 @@ dependencies = [
"parking_lot_core 0.9.8",
]
[[package]]
name = "data-encoding"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "debugid"
version = "0.8.0"
@ -994,6 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
@ -1069,7 +1167,16 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
@ -1093,6 +1200,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
@ -1151,6 +1270,7 @@ version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60f6d271ca33075c88028be6f04d502853d63a5ece419d269c15315d4fc1cf1d"
dependencies = [
"pkcs8",
"signature",
]
@ -1162,7 +1282,10 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"zeroize",
]
[[package]]
@ -1183,6 +1306,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
@ -1713,6 +1837,16 @@ dependencies = [
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "ghash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.12.0"
@ -1876,10 +2010,14 @@ dependencies = [
name = "gitbutler-git"
version = "0.0.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
"futures",
"git2",
"nix 0.27.1",
"rand 0.8.5",
"russh",
"russh-keys",
"serde",
"sysinfo",
"thiserror",
@ -2138,6 +2276,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-literal"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
[[package]]
name = "hmac"
version = "0.12.1"
@ -2409,6 +2553,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"block-padding",
"generic-array",
]
@ -3050,6 +3195,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-bigint"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
"rand 0.8.5",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
@ -3194,6 +3351,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "opaque-debug"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "open"
version = "3.2.0"
@ -3258,6 +3421,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -3437,6 +3606,15 @@ dependencies = [
"sha2",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -3705,6 +3883,29 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -4169,7 +4370,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321e5e41b3b192dab6f1e75b9deacb6688b4b8c5e68906a78e8f43e7c2887bb5"
dependencies = [
"dirs",
"dirs 4.0.0",
]
[[package]]
@ -4243,6 +4444,92 @@ dependencies = [
"smallvec",
]
[[package]]
name = "russh"
version = "0.41.0-beta.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f5a2a1836739e0dbbdb6efe481b37a540aea25ffa0af466ebb7790fc2f8f9a3"
dependencies = [
"aes",
"aes-gcm",
"async-trait",
"bitflags 2.4.0",
"byteorder",
"chacha20",
"ctr",
"curve25519-dalek",
"digest",
"flate2",
"futures",
"generic-array",
"hex-literal",
"hmac",
"log",
"num-bigint",
"once_cell",
"openssl",
"poly1305",
"rand 0.8.5",
"russh-cryptovec",
"russh-keys",
"sha1",
"sha2",
"subtle",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "russh-cryptovec"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b077b6dd8d8c085dac62f7fcc5a83df60c7f7a22d49bfba994f2f4dbf60bc74"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "russh-keys"
version = "0.41.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d04bf4f4bea01661f1d7574607ffa510bbb11d19ffa91cda44c24feaa6e0960"
dependencies = [
"aes",
"async-trait",
"bcrypt-pbkdf",
"bit-vec",
"block-padding",
"byteorder",
"cbc",
"ctr",
"data-encoding",
"dirs 5.0.1",
"ed25519-dalek",
"futures",
"hmac",
"inout",
"log",
"md5",
"num-bigint",
"num-integer",
"openssl",
"p256",
"p521",
"pbkdf2 0.11.0",
"rand 0.7.3",
"rand_core 0.6.4",
"russh-cryptovec",
"serde",
"sha1",
"sha2",
"thiserror",
"tokio",
"tokio-stream",
"yasna",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
@ -4698,7 +4985,7 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
dependencies = [
"dirs",
"dirs 5.0.1",
]
[[package]]
@ -5851,6 +6138,16 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "ureq"
version = "2.7.1"
@ -6648,6 +6945,16 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"bit-vec",
"num-bigint",
]
[[package]]
name = "zbus"
version = "3.14.1"
@ -6734,7 +7041,7 @@ dependencies = [
"crossbeam-utils",
"flate2",
"hmac",
"pbkdf2",
"pbkdf2 0.11.0",
"sha1",
"time",
"zstd",

View File

@ -18,23 +18,27 @@ required-features = ["cli"]
[features]
default = ["git2", "cli", "serde", "tokio"]
cli = ["std", "dep:nix", "dep:rand", "dep:futures", "dep:sysinfo"]
git2 = ["dep:git2", "std"]
cli = ["dep:nix", "dep:rand", "dep:futures", "dep:sysinfo"]
git2 = ["dep:git2", "dep:dirs"]
serde = ["dep:serde"]
std = ["dep:thiserror"]
tokio = ["dep:tokio"]
[dependencies]
thiserror.workspace = true
git2 = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["process", "rt", "process", "time", "io-util", "net", "fs"]}
tokio = { workspace = true, optional = true, features = ["process", "rt", "process", "time", "io-util", "net", "fs", "sync"]}
rand = { version = "0.8.5", optional = true }
futures = { version = "0.3.30", optional = true }
sysinfo = { version = "0.30.5", optional = true }
dirs = { version = "5.0.1", optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread"]}
git2.workspace = true # Used for tests
async-trait = "0.1.77"
russh = { version = "0.41.0-beta.4", features = ["openssl"] }
russh-keys = "0.41.0-beta.3"
tokio = { workspace = true, features = ["rt-multi-thread"] }
[target."cfg(unix)".dependencies]
nix = { version = "0.27.1", optional = true, features = ["process", "socket", "user"] }

View File

@ -1,4 +1,6 @@
#[cfg(feature = "cli")]
pub mod cli;
#[cfg(feature = "git2")]
// We use the libgit2 backend for tests as well.
#[cfg(any(test, feature = "git2"))]
pub mod git2;

View File

@ -27,10 +27,11 @@ mod tests {
.join(test_name);
let _ = std::fs::remove_dir_all(&repo_path);
std::fs::create_dir_all(&repo_path).unwrap();
Repository::open_or_init(executor::tokio::TokioExecutor, repo_path.to_str().unwrap())
.await
.unwrap()
}
crate::gitbutler_git_integration_tests!(make_repo);
crate::gitbutler_git_integration_tests!(make_repo, enable_io);
}

View File

@ -1,36 +1,33 @@
use std::io::{Read, Write};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::os::unix::net::UnixStream;
pub fn main(sock_path: &str, secret: &str, prompt: &str) {
let mut stream = UnixStream::connect(sock_path).expect("connect():");
let raw_stream = UnixStream::connect(sock_path).expect("connect():");
// Set a timer for 10s.
stream
raw_stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.expect("set_read_timeout():");
let mut reader = BufReader::new(raw_stream.try_clone().unwrap());
let mut writer = BufWriter::new(raw_stream);
// Write the secret.
stream
.write_all(secret.as_bytes())
.expect("write_all(secret):");
writeln!(writer, "{secret}").expect("write(secret):");
// Write the prompt that Git gave us.
stream
.write_all(prompt.as_bytes())
.expect("write_all(prompt):");
writeln!(writer, "{prompt}").expect("write(prompt):");
writer.flush().expect("flush():");
// Wait for the response.
let mut buf = [0; 2048];
let n = stream.read(&mut buf).expect("read():");
// TODO(qix-): Figure out a way to do a single timeout
// TODO(qix-): but allow any response size.
if n == buf.len() {
panic!("response too long");
let mut password = String::new();
let nread = reader.read_line(&mut password).expect("read_line():");
if nread == 0 {
panic!("read_line() returned 0");
}
// Write the response back to Git.
std::io::stdout()
.write_all(&buf[..n])
.expect("write_all(stdout):");
// `password` already has a newline at the end.
write!(std::io::stdout(), "{password}").expect("write(password):");
}

View File

@ -13,7 +13,7 @@ pub fn main() {
#[cfg(not(target_os = "windows"))]
pub fn main() {
let has_pipe_var = std::env::var("GITBUTLER_ASKPASS_PIPE")
.map(|v| v != "")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has_pipe_var {
panic!("This binary is only meant to be run by GitButler; please do not use it yourself as it's entirely unstable.");

View File

@ -1,5 +1,4 @@
use crate::prelude::*;
use core::time::Duration;
use std::{collections::HashMap, time::Duration};
#[cfg(any(test, feature = "tokio"))]
pub mod tokio;
@ -42,7 +41,7 @@ pub unsafe trait GitExecutor {
///
/// Otherwise, `Ok` is returned in call cases, even when
/// the exit code is non-zero.
type Error: core::error::Error + core::fmt::Debug + Send + Sync + 'static;
type Error: std::error::Error + core::fmt::Debug + Send + Sync + 'static;
/// The type of the handle returned by [`GitExecutor::create_askpass_server`].
type ServerHandle: AskpassServer + Send + Sync + 'static;
@ -60,7 +59,7 @@ pub unsafe trait GitExecutor {
async fn execute_raw(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
envs: Option<HashMap<String, String>>,
) -> Result<(usize, String, String), Self::Error>;
/// Executes the given Git command with sane defaults.
@ -71,7 +70,7 @@ pub unsafe trait GitExecutor {
async fn execute(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
envs: Option<HashMap<String, String>>,
) -> Result<(usize, String, String), Self::Error> {
let mut args = args.as_ref().to_vec();
@ -174,7 +173,7 @@ pub struct FileStat {
/// Upon dropping the handle, the server should be closed.
pub trait AskpassServer: core::fmt::Display {
/// The type of error that is returned by [`AskpassServer::accept`].
type Error: core::error::Error + core::fmt::Debug + Send + Sync + 'static;
type Error: std::error::Error + core::fmt::Debug + Send + Sync + 'static;
/// The type of the socket yielded by the incoming iterator.
type SocketHandle: Socket + Send + Sync + 'static;
@ -202,7 +201,7 @@ pub type Uid = u32;
/// is established.
pub trait Socket {
/// The error type returned by I/O operations on this socket.
type Error: core::error::Error + core::fmt::Debug + Send + Sync + 'static;
type Error: std::error::Error + core::fmt::Debug + Send + Sync + 'static;
/// The process ID of the connecting client.
fn pid(&self) -> Result<Pid, Self::Error>;

View File

@ -1,10 +1,8 @@
//! A [Tokio](https://tokio.rs)-based [`super::GitExecutor`] implementation.
use crate::prelude::*;
use core::time::Duration;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
use std::{collections::HashMap, fs::Permissions, os::unix::fs::PermissionsExt, time::Duration};
use tokio::process::Command;
/// A [`super::GitExecutor`] implementation using the `git` command-line tool
@ -19,10 +17,24 @@ unsafe impl super::GitExecutor for TokioExecutor {
async fn execute_raw(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
envs: Option<HashMap<String, String>>,
) -> Result<(usize, String, String), Self::Error> {
let mut cmd = Command::new("git");
// Output the command being executed to stderr, for debugging purposes
// (only on test configs).
#[cfg(test)]
{
let mut envs_str = String::new();
if let Some(envs) = &envs {
for (key, value) in envs.iter() {
envs_str.push_str(&format!("{}={} ", key, value));
}
}
let args_str = args.join(" ");
eprintln!("env {envs_str} git {args_str}");
}
cmd.kill_on_drop(true);
cmd.args(args);
@ -88,12 +100,13 @@ impl super::Socket for tokio::io::BufStream<tokio::net::UnixStream> {
async fn read_line(&mut self) -> Result<String, Self::Error> {
let mut buf = String::new();
<Self as tokio::io::AsyncBufReadExt>::read_line(self, &mut buf).await?;
Ok(buf)
Ok(buf.trim_end_matches(|c| c == '\r' || c == '\n').into())
}
async fn write_line(&mut self, line: &str) -> Result<(), Self::Error> {
<Self as tokio::io::AsyncWriteExt>::write_all(self, line.as_bytes()).await?;
<Self as tokio::io::AsyncWriteExt>::write_all(self, b"\n").await?;
<Self as tokio::io::AsyncWriteExt>::flush(self).await?;
Ok(())
}
}
@ -119,7 +132,7 @@ impl super::AskpassServer for TokioAskpassServer {
self.server.as_ref().unwrap().accept().await
};
Ok(res.map(|(s, _)| tokio::io::BufStream::new(s))?)
res.map(|(s, _)| tokio::io::BufStream::new(s))
}
}

View File

@ -1,8 +1,8 @@
use super::executor::{AskpassServer, GitExecutor, Pid, Socket};
use crate::{prelude::*, Authorization, ConfigScope};
use core::time::Duration;
use crate::{Authorization, ConfigScope, RefSpec};
use futures::{select, FutureExt};
use rand::Rng;
use std::{collections::HashMap, time::Duration};
/// The number of characters in the secret used for checking
/// askpass invocations by ssh/git when connecting to our process.
@ -13,9 +13,9 @@ const ASKPASS_SECRET_LENGTH: usize = 24;
/// You probably don't want to use this type. Use [`Error`] instead.
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError<
Eexec: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
Easkpass: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
Esocket: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
Eexec: std::error::Error + core::fmt::Debug + Send + Sync + 'static,
Easkpass: std::error::Error + core::fmt::Debug + Send + Sync + 'static,
Esocket: std::error::Error + core::fmt::Debug + Send + Sync + 'static,
> {
#[error("failed to execute git command: {0}")]
Exec(Eexec),
@ -24,9 +24,14 @@ pub enum RepositoryError<
#[error("i/o error communicating with askpass utility: {0}")]
AskpassIo(Esocket),
#[error(
"git command exited with non-zero exit code {0}: {1:?}\n\nSTDOUT:\n{2}\n\nSTDERR:\n{3}"
"git command exited with non-zero exit code {status}: {args:?}\n\nSTDOUT:\n{stdout}\n\nSTDERR:\n{stderr}"
)]
Failed(usize, Vec<String>, String, String),
Failed {
status: usize,
args: Vec<String>,
stdout: String,
stderr: String,
},
#[error("failed to determine path to this executable: {0}")]
NoSelfExe(std::io::Error),
#[error("askpass secret mismatch")]
@ -75,6 +80,7 @@ impl<E: GitExecutor> Repository<E> {
/// (Re-)initializes a repository at the given path
/// using the given [`GitExecutor`].
#[cold]
pub async fn open_or_init<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E>> {
let path = path.as_ref().to_owned();
let args = vec!["init", "--quiet", &path];
@ -85,28 +91,62 @@ impl<E: GitExecutor> Repository<E> {
if exit_code == 0 {
Ok(Self { exec, path })
} else {
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
Err(Error::<E>::Failed {
status: exit_code,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
))
})
}
}
/// (Re-)initializes a bare repository at the given path
/// using the given [`GitExecutor`].
#[cold]
pub async fn open_or_init_bare<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E>> {
let path = path.as_ref().to_owned();
let args = vec!["init", "--bare", "--quiet", &path];
let (exit_code, stdout, stderr) =
exec.execute(&args, None).await.map_err(Error::<E>::Exec)?;
if exit_code == 0 {
Ok(Self { exec, path })
} else {
Err(Error::<E>::Failed {
status: exit_code,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
})
}
}
#[cold]
async fn execute_with_auth_harness(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
envs: Option<HashMap<String, String>>,
authorization: &Authorization,
) -> Result<(usize, String, String), Error<E>> {
let path = std::env::current_exe().map_err(|e| Error::<E>::NoSelfExe(e))?;
let our_pid = std::process::id();
let path = std::env::current_exe().map_err(Error::<E>::NoSelfExe)?;
// TODO(qix-): Get parent PID of connecting processes to make sure they're us.
//let our_pid = std::process::id();
// TODO(qix-): This is a bit of a hack. Under a test environment,
// TODO(qix-): Cargo is running a test runner with a quasi-random
// TODO(qix-): suffix. The actual executables live in the parent directory.
// TODO(qix-): Thus, we have to do this under test. It's not ideal, but
// TODO(qix-): it works for now.
#[cfg(test)]
let path = path.parent().unwrap();
let askpath_path = path
.with_file_name("gitbutler-git-askpass")
.to_string_lossy()
.into_owned();
#[cfg(not(target_os = "windows"))]
let setsid_path = path
.with_file_name("gitbutler-git-setsid")
@ -118,6 +158,7 @@ impl<E: GitExecutor> Repository<E> {
.stat(&askpath_path)
.await
.map_err(Error::<E>::Exec)?;
#[cfg(not(target_os = "windows"))]
let setsid_stat = self
.exec
@ -125,7 +166,10 @@ impl<E: GitExecutor> Repository<E> {
.await
.map_err(Error::<E>::Exec)?;
let sock_path = std::env::temp_dir().join(format!("gitbutler-git-{our_pid}.sock"));
#[allow(unsafe_code)]
let sock_server = unsafe { self.exec.create_askpass_server() }
.await
.map_err(Error::<E>::Exec)?;
// FIXME(qix-): This is probably not cryptographically secure, did this in a bit
// FIXME(qix-): of a hurry. We should probably use a proper CSPRNG here, but this
@ -138,50 +182,77 @@ impl<E: GitExecutor> Repository<E> {
.collect::<String>();
let mut envs = envs.unwrap_or_default();
envs.insert(
"GITBUTLER_ASKPASS_PIPE".into(),
sock_path.to_string_lossy().into_owned(),
);
envs.insert("GITBUTLER_ASKPASS_PIPE".into(), sock_server.to_string());
envs.insert("GITBUTLER_ASKPASS_SECRET".into(), secret.clone());
envs.insert("SSH_ASKPASS".into(), askpath_path);
// DISPLAY is required by SSH to check SSH_ASKPASS.
// Please don't ask us why, it's unclear.
if !std::env::var("DISPLAY").map(|v| v != "").unwrap_or(false) {
if !std::env::var("DISPLAY")
.map(|v| !v.is_empty())
.unwrap_or(false)
{
envs.insert("DISPLAY".into(), ":".into());
}
#[cfg(not(target_os = "windows"))]
envs.insert(
"GIT_SSH_COMMAND".into(),
format!(
"{} {}",
setsid_path,
envs.get("GIT_SSH_COMMAND").unwrap_or(&"ssh".into())
"{}{}{} -o StrictHostKeyChecking=accept-new -o KbdInteractiveAuthentication=no{}",
{
#[cfg(not(target_os = "windows"))]
{
format!("{setsid_path} ")
}
#[cfg(target_os = "windows")]
{
""
}
},
envs.get("GIT_SSH_COMMAND").unwrap_or(&"ssh".into()),
match authorization {
Authorization::Ssh { .. } => " -o PreferredAuthentications=publickey",
Authorization::Basic { .. } => " -o PreferredAuthentications=password",
_ => "",
},
{
// In test environments, we don't want to pollute the user's known hosts file.
// So, we just use /dev/null instead.
#[cfg(test)]
{
" -o UserKnownHostsFile=/dev/null"
}
#[cfg(not(test))]
{
""
}
}
),
);
if let Authorization::Ssh { private_key, .. } = authorization {
if let Some(private_key) = private_key {
envs.insert("GIT_SSH_VARIANT".into(), "ssh".into());
envs.insert("GIT_SSH_KEY".into(), private_key.clone());
}
if let Authorization::Ssh {
private_key: Some(private_key),
..
} = authorization
{
envs.insert("GIT_SSH_VARIANT".into(), "ssh".into());
envs.insert("GIT_SSH_KEY".into(), private_key.clone());
}
#[allow(unsafe_code)]
let sock_server = unsafe { self.exec.create_askpass_server() }
.await
.map_err(Error::<E>::Exec)?;
let mut child_process = core::pin::pin! {async {
self.exec
.execute(args, Some(envs))
.await
.map_err(Error::<E>::Exec)
}.fuse()};
let mut child_process = core::pin::pin! {
async {
self.exec
.execute(args, Some(envs))
.await
.map_err(Error::<E>::Exec)
}.fuse()
};
loop {
select! {
res = child_process => {
return res;
},
res = sock_server.accept(Some(Duration::from_secs(60))).fuse() => {
let mut sock = res.map_err(Error::<E>::AskpassServer)?;
@ -202,14 +273,14 @@ impl<E: GitExecutor> Repository<E> {
if peer_stat.ino == askpath_stat.ino {
if peer_stat.dev != askpath_stat.dev {
return Err(Error::<E>::AskpassDeviceMismatch);
return Err(Error::<E>::AskpassDeviceMismatch)?;
}
} else if peer_stat.ino == setsid_stat.ino {
if peer_stat.dev != setsid_stat.dev {
return Err(Error::<E>::AskpassDeviceMismatch);
return Err(Error::<E>::AskpassDeviceMismatch)?;
}
} else {
return Err(Error::<E>::AskpassExecutableMismatch);
return Err(Error::<E>::AskpassExecutableMismatch)?;
}
// await for peer to send secret
@ -217,39 +288,47 @@ impl<E: GitExecutor> Repository<E> {
// check the secret
if peer_secret.trim() != secret {
return Err(Error::<E>::AskpassSecretMismatch);
return Err(Error::<E>::AskpassSecretMismatch)?;
}
// get the prompt
let prompt = sock.read_line().await.map_err(Error::<E>::AskpassIo)?;
// TODO(qix-): The prompt matching logic here is fragile as the remote
// TODO(qix-): can customize prompts. I need to investigate if there's
// TODO(qix-): a better way to do this.
match authorization {
Authorization::Auto => {
return Err(Error::<E>::NeedsAuthorization(prompt));
return Err(Error::<E>::NeedsAuthorization(prompt))?;
}
Authorization::Basic{username, password} => {
if prompt.contains("Username for") {
sock.write_line(username).await.map_err(Error::<E>::AskpassIo)?;
} else if prompt.contains("Password for") {
sock.write_line(password).await.map_err(Error::<E>::AskpassIo)?;
if prompt.to_lowercase().contains("username:") || prompt.to_lowercase().contains("username for") {
if let Some(username) = username {
sock.write_line(username).await.map_err(Error::<E>::AskpassIo)?;
} else {
return Err(Error::<E>::NeedsAuthorization(prompt))?;
}
} else if prompt.to_lowercase().contains("password:") || prompt.to_lowercase().contains("password for") {
if let Some(password) = password {
sock.write_line(password).await.map_err(Error::<E>::AskpassIo)?;
} else {
return Err(Error::<E>::NeedsAuthorization(prompt))?;
}
} else {
return Err(Error::<E>::NeedsAuthorization(prompt));
return Err(Error::<E>::NeedsAuthorization(prompt))?;
}
},
Authorization::Ssh { passphrase, .. } => {
if let Some(passphrase) = passphrase {
if prompt.contains("passphrase for key") {
sock.write_line(passphrase).await.map_err(Error::<E>::AskpassIo)?;
continue;
}
if prompt.contains("passphrase for key") {
sock.write_line(passphrase).await.map_err(Error::<E>::AskpassIo)?;
continue;
}
}
return Err(Error::<E>::NeedsAuthorization(prompt));
return Err(Error::<E>::NeedsAuthorization(prompt))?;
}
}
},
res = child_process => {
return res;
}
}
}
@ -263,7 +342,7 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
&self,
key: &str,
scope: ConfigScope,
) -> Result<Option<String>, Self::Error> {
) -> Result<Option<String>, crate::Error<Self::Error>> {
let mut args = vec!["-C", &self.path, "config", "--get"];
// NOTE(qix-): See source comments for ConfigScope to explain
@ -291,12 +370,12 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
} else if exit_code == 1 && stderr.is_empty() {
Ok(None)
} else {
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
Err(Error::<E>::Failed {
status: exit_code,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
))
})?
}
}
@ -305,7 +384,7 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
key: &str,
value: &str,
scope: ConfigScope,
) -> Result<(), Self::Error> {
) -> Result<(), crate::Error<Self::Error>> {
let mut args = vec!["-C", &self.path, "config", "--replace-all"];
// NOTE(qix-): See source comments for ConfigScope to explain
@ -332,12 +411,159 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
if exit_code == 0 {
Ok(())
} else {
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
Err(Error::<E>::Failed {
status: exit_code,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
))
})?
}
}
async fn fetch(
&self,
remote: &str,
refspec: RefSpec,
authorization: &Authorization,
) -> Result<(), crate::Error<Self::Error>> {
let mut args = vec![
"-C",
&self.path,
"fetch",
"--quiet",
"--no-write-fetch-head",
];
let refspec = refspec.to_string();
args.push(remote);
args.push(&refspec);
let (status, stdout, stderr) = self
.execute_with_auth_harness(&args, None, authorization)
.await?;
if status == 0 {
Ok(())
} else {
// Was the ref not found?
if let Some(refname) = stderr
.lines()
.find(|line| line.to_lowercase().contains("couldn't find remote ref"))
.map(|line| line.split_whitespace().last().unwrap_or_default())
{
Err(crate::Error::RefNotFound(refname.to_owned()))?
} else if stderr.to_lowercase().contains("permission denied") {
Err(crate::Error::AuthorizationFailed(Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
}))?
} else {
Err(Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
})?
}
}
}
async fn create_remote(
&self,
remote: &str,
uri: &str,
) -> Result<(), crate::Error<Self::Error>> {
let args = vec!["-C", &self.path, "remote", "add", remote, uri];
let (status, stdout, stderr) = self
.exec
.execute(&args, None)
.await
.map_err(Error::<E>::Exec)?;
if status != 0 {
Err(Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
})?
} else {
Ok(())
}
}
async fn create_or_update_remote(
&self,
remote: &str,
uri: &str,
) -> Result<(), crate::Error<Self::Error>> {
let created = self
.create_remote(remote, uri)
.await
.map(|_| true)
.or_else(|e| match e {
crate::Error::RemoteExists(..) => Ok(false),
e => Err(e),
})?;
if created {
return Ok(());
}
let args = vec!["-C", &self.path, "remote", "set-url", remote, uri];
let (status, stdout, stderr) = self
.exec
.execute(&args, None)
.await
.map_err(Error::<E>::Exec)?;
if status == 0 {
Ok(())
} else if status != 0 && stderr.to_lowercase().contains("error: no such remote") {
self.create_remote(remote, uri).await
} else {
Err(Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
})?
}
}
async fn remote(&self, remote: &str) -> Result<String, crate::Error<Self::Error>> {
let args = vec!["-C", &self.path, "remote", "get-url", remote];
let (status, stdout, stderr) = self
.exec
.execute(&args, None)
.await
.map_err(Error::<E>::Exec)?;
if status == 0 {
Ok(stdout)
} else if status != 0 && stderr.to_lowercase().contains("error: no such remote") {
Err(crate::Error::NoSuchRemote(
remote.to_owned(),
Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
},
))?
} else {
Err(Error::<E>::Failed {
status,
args: args.into_iter().map(Into::into).collect(),
stdout,
stderr,
})?
}
}
}

View File

@ -4,8 +4,15 @@
//! The entry point for this module is the [`Repository`] struct.
mod repository;
mod thread_resource;
pub use self::repository::Repository;
#[cfg(feature = "tokio")]
pub use self::thread_resource::tokio;
pub use self::{
repository::Repository,
thread_resource::{ThreadedResource, ThreadedResourceHandle},
};
#[cfg(test)]
mod tests {
@ -19,8 +26,11 @@ mod tests {
.join(test_name);
let _ = std::fs::remove_dir_all(&repo_path);
std::fs::create_dir_all(&repo_path).unwrap();
Repository::open_or_init(&repo_path).unwrap()
Repository::<tokio::TokioThreadedResource>::open_or_init(&repo_path)
.await
.unwrap()
}
crate::gitbutler_git_integration_tests!(make_repo);
crate::gitbutler_git_integration_tests!(make_repo, disable_io);
}

View File

@ -1,34 +1,42 @@
use std::path::Path;
use crate::ConfigScope;
use super::{ThreadedResource, ThreadedResourceHandle};
use crate::{Authorization, ConfigScope, RefSpec};
use std::path::{Path, PathBuf};
/// A [`crate::Repository`] implementation using the `git2` crate.
pub struct Repository {
repo: git2::Repository,
pub struct Repository<R: ThreadedResource> {
repo: R::Handle<git2::Repository>,
}
impl Repository {
impl<R: ThreadedResource> Repository<R> {
/// Initializes a repository at the given path.
///
/// Errors if the repository is already initialized.
#[inline]
pub fn init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
pub async fn init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
let path = path.as_ref().to_path_buf();
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(true),
)?,
repo: R::new(|| {
git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(true),
)
})
.await?,
})
}
/// Opens a repository at the given path, or initializes it if it doesn't exist.
#[inline]
pub fn open_or_init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
pub async fn open_or_init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
let path = path.as_ref().to_path_buf();
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(false),
)?,
repo: R::new(|| {
git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(false),
)
})
.await?,
})
}
@ -36,77 +44,94 @@ impl Repository {
///
/// Errors if the repository is already initialized.
#[inline]
pub fn init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
pub async fn init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
let path = path.as_ref().to_path_buf();
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(true)
.bare(true),
)?,
repo: R::new(|| {
git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(true)
.bare(true),
)
})
.await?,
})
}
/// Opens a repository at the given path, or initializes a new bare repository
/// if it doesn't exist.
#[inline]
pub fn open_or_init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
pub async fn open_or_init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
let path = path.as_ref().to_path_buf();
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(false)
.bare(true),
)?,
repo: R::new(|| {
git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(false)
.bare(true),
)
})
.await?,
})
}
/// Opens a repository at the given path.
/// Will error if there's no existing repository at the given path.
#[inline]
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
pub async fn open<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
let path = path.as_ref().to_path_buf();
Ok(Self {
repo: git2::Repository::open(path)?,
repo: R::new(|| git2::Repository::open(path)).await?,
})
}
}
impl crate::Repository for Repository {
impl<R: ThreadedResource> crate::Repository for Repository<R> {
type Error = git2::Error;
async fn config_get(
&self,
key: &str,
#[cfg_attr(test, allow(unused_variables))] scope: ConfigScope,
) -> Result<Option<String>, Self::Error> {
let config = self.repo.config()?;
) -> Result<Option<String>, crate::Error<Self::Error>> {
let key = key.to_owned();
self.repo
.with(move |repo| {
let config = repo.config()?;
#[cfg(test)]
let scope = ConfigScope::Local;
#[cfg(test)]
let scope = ConfigScope::Local;
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
let res = match scope {
#[cfg(not(test))]
ConfigScope::Auto => config.get_string(key),
ConfigScope::Local => config.open_level(git2::ConfigLevel::Local)?.get_string(key),
#[cfg(not(test))]
ConfigScope::System => config
.open_level(git2::ConfigLevel::System)?
.get_string(key),
#[cfg(not(test))]
ConfigScope::Global => config
.open_level(git2::ConfigLevel::Global)?
.get_string(key),
};
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
let res = match scope {
#[cfg(not(test))]
ConfigScope::Auto => config.get_string(&key),
ConfigScope::Local => config
.open_level(git2::ConfigLevel::Local)?
.get_string(&key),
#[cfg(not(test))]
ConfigScope::System => config
.open_level(git2::ConfigLevel::System)?
.get_string(&key),
#[cfg(not(test))]
ConfigScope::Global => config
.open_level(git2::ConfigLevel::Global)?
.get_string(&key),
};
res.map(Some).or_else(|e| {
if e.code() == git2::ErrorCode::NotFound {
Ok(None)
} else {
Err(e)
}
})
Ok(res.map(Some).or_else(|e| {
if e.code() == git2::ErrorCode::NotFound {
Ok(None)
} else {
Err(e)
}
})?)
})
.await
.await
}
async fn config_set(
@ -114,29 +139,190 @@ impl crate::Repository for Repository {
key: &str,
value: &str,
#[cfg_attr(test, allow(unused_variables))] scope: ConfigScope,
) -> Result<(), Self::Error> {
#[cfg_attr(test, allow(unused_mut))]
let mut config = self.repo.config()?;
) -> Result<(), crate::Error<Self::Error>> {
let key = key.to_owned();
let value = value.to_owned();
#[cfg(test)]
let scope = ConfigScope::Local;
self.repo
.with(move |repo| {
#[cfg_attr(test, allow(unused_mut))]
let mut config = repo.config()?;
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
match scope {
#[cfg(not(test))]
ConfigScope::Auto => config.set_str(key, value),
ConfigScope::Local => config
.open_level(git2::ConfigLevel::Local)?
.set_str(key, value),
#[cfg(not(test))]
ConfigScope::System => config
.open_level(git2::ConfigLevel::System)?
.set_str(key, value),
#[cfg(not(test))]
ConfigScope::Global => config
.open_level(git2::ConfigLevel::Global)?
.set_str(key, value),
}
#[cfg(test)]
let scope = ConfigScope::Local;
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
match scope {
#[cfg(not(test))]
ConfigScope::Auto => Ok(config.set_str(&key, &value)?),
ConfigScope::Local => Ok(config
.open_level(git2::ConfigLevel::Local)?
.set_str(&key, &value)?),
#[cfg(not(test))]
ConfigScope::System => Ok(config
.open_level(git2::ConfigLevel::System)?
.set_str(&key, &value)?),
#[cfg(not(test))]
ConfigScope::Global => Ok(config
.open_level(git2::ConfigLevel::Global)?
.set_str(&key, &value)?),
}
})
.await
.await
}
async fn fetch(
&self,
remote: &str,
refspec: RefSpec,
authorization: &Authorization,
) -> Result<(), crate::Error<Self::Error>> {
let remote = remote.to_owned();
let authorization = authorization.clone();
self.repo
.with(move |repo| {
let mut remote = repo.find_remote(&remote)?;
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username, _allowed| {
let auth = match &authorization {
Authorization::Auto => {
let cred = git2::Cred::default()?;
Ok(cred)
}
Authorization::Basic { username, password } => {
let username = username.as_deref().unwrap_or_default();
let password = password.as_deref().unwrap_or_default();
git2::Cred::userpass_plaintext(username, password)
}
Authorization::Ssh {
passphrase,
private_key,
} => {
let private_key =
private_key.as_ref().map(PathBuf::from).unwrap_or_else(|| {
let mut path = dirs::home_dir().unwrap();
path.push(".ssh");
path.push("id_rsa");
path
});
let username = username
.map(ToOwned::to_owned)
.unwrap_or_else(|| std::env::var("USER").unwrap_or_default());
git2::Cred::ssh_key(
&username,
None,
&private_key,
passphrase.clone().as_deref(),
)
}
};
auth
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
let refspec = refspec.to_string();
let r = remote.fetch(&[&refspec], Some(&mut fetch_options), None);
r.map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
crate::Error::RefNotFound(refspec)
} else {
e.into()
}
})
})
.await
.await
}
async fn create_remote(
&self,
remote: &str,
uri: &str,
) -> Result<(), crate::Error<Self::Error>> {
let remote = remote.to_owned();
let uri = uri.to_owned();
self.repo
.with(move |repo| {
repo.remote(&remote, &uri).map_err(|e| {
if e.code() == git2::ErrorCode::Exists {
crate::Error::RemoteExists(remote.to_owned(), e)
} else {
e.into()
}
})?;
Ok(())
})
.await
.await
}
async fn create_or_update_remote(
&self,
remote: &str,
uri: &str,
) -> Result<(), crate::Error<Self::Error>> {
let remote = remote.to_owned();
let uri = uri.to_owned();
self.repo
.with(move |repo| {
let r = repo
.find_remote(&remote)
.and_then(|_| repo.remote_set_url(&remote, &uri));
if let Err(e) = r {
if e.code() == git2::ErrorCode::NotFound {
repo.remote(&remote, &uri)?;
} else {
Err(e)?
}
}
Ok(())
})
.await
.await
}
async fn remote(&self, remote: &str) -> Result<String, crate::Error<Self::Error>> {
let remote = remote.to_owned();
self.repo
.with(move |repo| {
let r = repo.find_remote(&remote);
let r = match r {
Err(e) if e.code() == git2::ErrorCode::NotFound => {
return Err(crate::Error::NoSuchRemote(remote, e))?;
}
Err(e) => {
return Err(e)?;
}
Ok(r) => r,
};
let url = r.url().ok_or_else(|| {
crate::Error::NoSuchRemote(remote, git2::Error::from_str("remote has no URL"))
})?;
Ok(url.to_string())
})
.await
.await
}
}

View File

@ -0,0 +1,56 @@
#[cfg(any(test, feature = "tokio"))]
pub mod tokio;
/// A resource that is held on an owning thread, and that can be
/// asynchronously locked and interacted with via lambda functions.
///
/// This is used to interact with `git2` resources in a thread-safe
/// manner, since `git2` is not thread-safe nor asynchronous.
pub trait ThreadedResource {
/// The type of handle returned by [`Self::new`].
type Handle<T: Unpin + Sized + 'static>: ThreadedResourceHandle<T>;
/// Creates a new resource; the function passed in will be
/// executed on the owning thread, the result of which becomes
/// the owned value that is later interacted with.
async fn new<T, F, E>(f: F) -> Result<Self::Handle<T>, E>
where
F: FnOnce() -> Result<T, E> + Send + 'static,
T: Unpin + Sized + 'static,
E: Send + 'static;
}
/// A handle to a resource that is held on an owning thread.
/// This handle can be used to asynchronously lock the resource
/// and interact with it via lambda functions.
///
/// Returned by [`ThreadedResource::new`].
pub trait ThreadedResourceHandle<T: Unpin + Sized + 'static> {
/// The type of future returned by [`Self::with`].
type WithFuture<'a, R>: std::future::Future<Output = R> + Send
where
Self: 'a,
R: Send + Unpin + 'static;
/// Locks the resource, and passes the locked value to the given
/// function, which can then interact with it. The function is
/// executed on the owning thread, and the result is returned
/// to the calling thread asynchronously.
///
/// Note that this is an async-async function - meaning, it
/// must be awaited in order to receive the future that actually
/// executes the code, which itself must also be awaited.
//
// FIXME(qix-): I think I'm too stupid to understand pinning and phantom
// FIXME(qix-): data, regardless of how many times I deep-dive into it.
// FIXME(qix-): I'm now ~48 hours (nearly straight) into this problem,
// FIXME(qix-): and I've lost a great deal of sanity trying to figure out
// FIXME(qix-): how to make this work. For now, the async-async function
// FIXME(qix-): will have to do, but I'm not happy with it. If you know
// FIXME(qix-): how to make this work, please PLEASE please send a PR.
// FIXME(qix-): I'm losing sleep and hair over this.
async fn with<F, R>(&self, f: F) -> Self::WithFuture<'_, R>
where
F: FnOnce(&mut T) -> R + Send + Unpin + 'static,
R: Send + Unpin + 'static;
}

View File

@ -0,0 +1,177 @@
//! A [Tokio](https://tokio.rs)-based implementation for [libgit2](https://libgit2.org/)
//! repository backends, allowing normally blocking libgit2 operations to be run on a
//! threadpool, asynchronously.
use futures::Future;
use std::{
pin::Pin,
sync::{atomic::AtomicBool, Arc, Barrier, Mutex as SyncMutex},
task::{Context, Poll, Waker},
thread::{JoinHandle, Thread},
};
use tokio::sync::Mutex as AsyncMutex;
/// A [`super::ThreadedResource`] implementation using Tokio.
pub struct TokioThreadedResource;
/// A [`super::ThreadedResourceHandle`] implementation using Tokio.
pub struct TokioThreadedResourceHandle<T: Unpin + Sized + 'static> {
terminate: Arc<AtomicBool>,
thread: JoinHandle<()>,
access_control_mutex: Arc<AsyncMutex<()>>,
#[allow(clippy::type_complexity)]
slot: Arc<SyncMutex<Option<(Waker, Box<dyn FnOnce(&mut T) + Send>)>>>,
}
impl super::ThreadedResource for TokioThreadedResource {
type Handle<T: Unpin + Sized + 'static> = TokioThreadedResourceHandle<T>;
async fn new<T, F, E>(f: F) -> Result<Self::Handle<T>, E>
where
F: FnOnce() -> Result<T, E> + Send + 'static,
T: Unpin + Sized + 'static,
E: Send + 'static,
{
#[allow(clippy::type_complexity)]
let slot: Arc<SyncMutex<Option<(Waker, Box<dyn FnOnce(&mut T) + Send>)>>> =
Arc::new(SyncMutex::new(None));
let maybe_error = Arc::new(SyncMutex::new(None));
let barrier = Arc::new(Barrier::new(2));
let terminate_signal = Arc::new(AtomicBool::new(false));
let thread = std::thread::spawn({
let slot = Arc::clone(&slot);
let barrier = Arc::clone(&barrier);
let maybe_error = Arc::clone(&maybe_error);
let terminate_signal = Arc::clone(&terminate_signal);
move || {
let mut v = match f() {
Ok(v) => v,
Err(e) => {
*maybe_error.lock().unwrap() = Some(e);
barrier.wait();
return;
}
};
barrier.wait();
loop {
if terminate_signal.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
std::thread::park();
if terminate_signal.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
if let Some((waker, fun)) = slot.lock().unwrap().take() {
fun(&mut v);
waker.wake();
} else {
break;
}
}
}
});
barrier.wait();
if let Some(e) = maybe_error.lock().unwrap().take() {
return Err(e);
}
Ok(TokioThreadedResourceHandle {
thread,
slot,
access_control_mutex: Arc::new(AsyncMutex::new(())),
terminate: terminate_signal,
})
}
}
impl<T> Drop for TokioThreadedResourceHandle<T>
where
T: Unpin + Sized + 'static,
{
fn drop(&mut self) {
self.terminate
.store(true, std::sync::atomic::Ordering::SeqCst);
self.thread.thread().unpark();
}
}
impl<T> super::ThreadedResourceHandle<T> for TokioThreadedResourceHandle<T>
where
T: Unpin + Sized + 'static,
{
type WithFuture<'a, R> = impl Future<Output = R> + Send
where
Self: 'a,
R: Send + Unpin + 'static;
async fn with<F, R>(&self, f: F) -> Self::WithFuture<'_, R>
where
F: FnOnce(&mut T) -> R + Send + Unpin + 'static,
R: Send + Unpin + 'static,
{
let guard = self.access_control_mutex.lock().await;
let result_slot = Arc::new(SyncMutex::new(Option::<R>::None));
let result_slot_clone = Arc::clone(&result_slot);
let slot = Arc::clone(&self.slot);
let boxed_f = Box::new(move |v: &mut T| {
*result_slot.lock().unwrap() = Some(f(v));
});
TokioThreadedResourceHandleFuture {
set_fun: Some(Box::new(move |waker| {
slot.lock().unwrap().replace((waker, boxed_f));
})),
result_slot: result_slot_clone,
handle: self.thread.thread(),
_access_guard: guard,
}
}
}
/// The future returned by [`TokioThreadedResourceHandle`]::with.
pub struct TokioThreadedResourceHandleFuture<'thread, R, Guard>
where
R: Send + Unpin + 'static,
Guard: Unpin,
{
set_fun: Option<Box<dyn FnOnce(Waker) + Send + Unpin + 'static>>,
result_slot: Arc<SyncMutex<Option<R>>>,
_access_guard: Guard,
handle: &'thread Thread,
}
impl<'thread, R, Guard> Future for TokioThreadedResourceHandleFuture<'thread, R, Guard>
where
R: Send + Unpin + 'static,
Guard: Unpin,
{
type Output = R;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<R> {
let this = self.as_mut().get_mut();
if let Some(set_fun) = this.set_fun.take() {
set_fun(cx.waker().clone());
this.handle.unpark();
return Poll::Pending;
}
if let Ok(mut result_slot) = this.result_slot.try_lock() {
if let Some(result) = result_slot.take() {
return Poll::Ready(result);
}
}
Poll::Pending
}
}

View File

@ -1,3 +1,5 @@
pub(crate) mod private;
/// To use in a backend, create a function that initializes
/// an empty repository, whatever that looks like, and returns
/// something that implements the `Repository` trait.
@ -20,11 +22,11 @@
/// ```
#[allow(unused_macros)]
macro_rules! gitbutler_git_integration_tests {
($create_repo:expr) => {
$crate::gitbutler_git_integration_tests! {
$create_repo,
($create_repo:expr, $io_tests:tt) => {
$crate::private::test_impl! {
$create_repo, enable_io,
async fn create_repo_selftest(_repo) {
async fn create_repo_selftest(repo) {
// Do-nothing, just a selftest.
}
@ -35,30 +37,94 @@ macro_rules! gitbutler_git_integration_tests {
crate::ops::set_utmost_discretion(&repo, false).await.unwrap();
assert_eq!(crate::ops::has_utmost_discretion(&repo).await.unwrap(), false);
}
async fn non_existent_remote(repo) {
use crate::*;
match repo.remote("non-existent").await.unwrap_err() {
Error::NoSuchRemote(remote, _) => assert_eq!(remote, "non-existent"),
err => panic!("expected NoSuchRemote, got {:?}", err),
}
}
async fn create_remote(repo) {
use crate::*;
match repo.remote("origin").await {
Err($crate::Error::NoSuchRemote(remote, _)) if remote == "origin" => {},
result => panic!("expected remote 'origin' query to fail with NoSuchRemote, but got {result:?}")
}
repo.create_remote("origin", "https://example.com/test.git").await.unwrap();
assert_eq!(repo.remote("origin").await.unwrap(), "https://example.com/test.git".to_owned());
}
}
$crate::private::test_impl! {
$create_repo, $io_tests,
async fn fetch_with_ssh_basic_bad_password(repo, server, server_repo) {
use crate::*;
server.allow_authorization(Authorization::Basic {
username: Some("my_username".to_owned()),
password: Some("my_password".to_owned())
});
server.run_with_server(async move |port| {
repo.create_remote("origin", &format!("[my_username@localhost:{port}]:test.git")).await.unwrap();
let err = repo.fetch(
"origin",
RefSpec{
source: Some("refs/heads/master".to_owned()),
destination: Some("refs/heads/master".to_owned()),
..Default::default()
},
&Authorization::Basic {
username: Some("my_username".to_owned()),
password: Some("wrong_password".to_owned()),
}
).await.unwrap_err();
match err {
Error::AuthorizationFailed(_) => {},
_ => panic!("expected AuthorizationFailed, got {:?}", err),
}
}).await
}
async fn fetch_with_ssh_basic_no_master(repo, server, server_repo) {
use crate::*;
let auth = Authorization::Basic {
username: Some("my_username".to_owned()),
password: Some("my_password".to_owned()),
};
server.allow_authorization(auth.clone());
server.run_with_server(async move |port| {
repo.create_remote("origin", &format!("[my_username@localhost:{port}]:test.git")).await.unwrap();
let err = repo.fetch(
"origin",
RefSpec{
source: Some("refs/heads/master".to_owned()),
destination: Some("refs/heads/master".to_owned()),
..Default::default()
},
&auth
).await.unwrap_err();
if let Error::RefNotFound(refname) = err {
assert_eq!(refname, "refs/heads/master");
} else {
panic!("expected RefNotFound, got {:?}", err);
}
}).await
}
}
};
// Don't use this one from your backend. This is an internal macro.
($create_repo:expr, $(async fn $name:ident($repo:ident) { $($body:tt)* })*) => {
$(
#[test]
fn $name() {
::tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let $repo = $create_repo({
let mod_name = ::std::module_path!();
let test_name = ::std::stringify!($name);
format!("{mod_name}::{test_name}")
}).await;
$($body)*
})
}
)*
}
}
#[allow(unused_imports)]

View File

@ -0,0 +1,355 @@
use futures::FutureExt;
use russh::{server, Channel, ChannelId, MethodSet, Pty};
use std::{collections::HashMap, process::Stdio, sync::Arc};
use tokio::net::TcpListener;
#[derive(Debug)]
pub(crate) struct TestSshServer {
repo_path: String,
allowed_auths: Vec<crate::Authorization>,
}
impl TestSshServer {
pub fn new(repo_path: String) -> Self {
Self {
repo_path,
allowed_auths: Vec::new(),
}
}
pub async fn run_with_server<F, FN>(self, cb: FN)
where
FN: FnOnce(u16) -> F,
F: std::future::Future<Output = ()> + 'static,
{
// We manually set up a TcpListener here so that we can
// bind to a random port and retrieve it.
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
let config = Arc::new(russh::server::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(10)),
auth_rejection_time: std::time::Duration::from_secs(3),
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
keys: vec![russh_keys::key::KeyPair::generate_ed25519().unwrap()],
..Default::default()
});
let socket_future = russh::server::run_on_socket(config, &listener, self);
futures::select! {
_ = cb(port).fuse() => {},
_ = socket_future.fuse() => {
panic!("server exited prematurely");
},
}
}
#[allow(unused)]
pub fn allow_authorization(&mut self, auth: crate::Authorization) {
self.allowed_auths.push(auth);
}
}
impl server::Server for TestSshServer {
type Handler = TestSshClient;
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Self::Handler {
TestSshClient {
repo_path: self.repo_path.clone(),
channels: HashMap::new(),
allowed_auths: self.allowed_auths.clone(),
}
}
}
#[derive(Debug)]
pub(crate) struct TestSshClient {
repo_path: String,
channels: HashMap<ChannelId, TestSshChannel>,
allowed_auths: Vec<crate::Authorization>,
}
#[derive(Debug)]
struct TestSshChannel {
envs: HashMap<String, String>,
channel: Channel<server::Msg>,
}
#[async_trait::async_trait]
impl server::Handler for TestSshClient {
type Error = russh::Error;
async fn auth_password(
self,
user: &str,
pass: &str,
) -> Result<(Self, server::Auth), Self::Error> {
for auth in &self.allowed_auths {
if let crate::Authorization::Basic { username, password } = auth {
if username.as_deref() == Some(user) && password.as_deref() == Some(pass) {
return Ok((self, server::Auth::Accept));
}
}
}
Ok((
self,
server::Auth::Reject {
proceed_with_methods: Some(MethodSet::PUBLICKEY),
},
))
}
async fn env_request(
mut self,
channel: ChannelId,
name: &str,
value: &str,
session: server::Session,
) -> Result<(Self, server::Session), Self::Error> {
match name {
"GIT_PROTOCOL" | "LANG" | "LC_ALL" => {
self.channels
.get_mut(&channel)
.expect("env_request on unknown channel")
.envs
.insert(name.to_owned(), value.to_owned());
}
disallowed => {
panic!(
"client attempted to set disallowed environment variable {:?} to {:?}",
disallowed, value
)
}
}
Ok((self, session))
}
async fn pty_request(
self,
_channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(Pty, u32)],
_session: server::Session,
) -> Result<(Self, server::Session), Self::Error> {
panic!("client requested a pty but we don't support that");
}
async fn shell_request(
self,
_channel: ChannelId,
_session: server::Session,
) -> Result<(Self, server::Session), Self::Error> {
panic!("client requested a shell but we don't support that");
}
async fn exec_request(
mut self,
channel_id: ChannelId,
command: &[u8],
session: server::Session,
) -> Result<(Self, server::Session), Self::Error> {
let req = String::from_utf8_lossy(command);
if req.starts_with("git-upload-pack") {
let channel = Box::leak(Box::new(self.channels.remove(&channel_id).unwrap()));
let repo_path = self.repo_path.clone();
let handle = session.handle();
tokio::spawn(async move {
let channel_id = channel.channel.id();
let mut writer = channel.channel.make_writer_ext(None);
let mut reader = channel.channel.make_reader_ext(None);
let mut cmd = tokio::process::Command::new("git-upload-pack")
.kill_on_drop(true)
.envs(channel.envs.iter())
.arg(&repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = cmd.stdin.take().unwrap();
let mut stdout = cmd.stdout.take().unwrap();
let copy_in = tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
//let file = tokio::fs::File::create("/tmp/gitbutler-upload-pack-in.log")
// .await
// .unwrap();
//let mut file_writer = tokio::io::BufWriter::new(file);
let mut buffer = [0; 1024];
while let Ok(n) = reader.read(&mut buffer).await {
if n == 0 {
break;
}
stdin.write_all(&buffer[..n]).await.unwrap();
//file_writer.write_all(&buffer[..n]).await.unwrap();
stdin.flush().await.unwrap();
//file_writer.flush().await.unwrap();
}
stdin.shutdown().await.ok(); // may have already been closed
//file_writer.shutdown().await.unwrap();
});
let copy_out = tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
//let file = tokio::fs::File::create("/tmp/gitbutler-upload-pack-out.log")
// .await
// .unwrap();
//let mut file_writer = tokio::io::BufWriter::new(file);
let mut buffer = [0; 1024];
while let Ok(n) = stdout.read(&mut buffer).await {
if n == 0 {
break;
}
writer.write_all(&buffer[..n]).await.unwrap();
//file_writer.write_all(&buffer[..n]).await.unwrap();
writer.flush().await.unwrap();
//file_writer.flush().await.unwrap();
}
writer.shutdown().await.ok(); // may have already been closed.
//file_writer.shutdown().await.unwrap();
});
let cmd_future = tokio::spawn(async move { cmd.wait().await.unwrap() });
let (status, _, _) = futures::try_join!(cmd_future, copy_in, copy_out).unwrap();
let exit_code = status.code().unwrap_or(1) as u32;
handle
.exit_status_request(channel_id, exit_code)
.await
.unwrap();
handle.close(channel_id).await.unwrap();
});
} else {
panic!("client requested a command we don't support: {:?}", req);
}
Ok((self, session))
}
async fn channel_open_session(
mut self,
channel: Channel<server::Msg>,
session: server::Session,
) -> Result<(Self, bool, server::Session), Self::Error> {
self.channels.insert(
channel.id(),
TestSshChannel {
channel,
envs: HashMap::new(),
},
);
Ok((self, true, session))
}
async fn channel_close(
mut self,
channel: ChannelId,
session: server::Session,
) -> Result<(Self, server::Session), Self::Error> {
// Best effort; may already be consumed.
self.channels.remove(&channel);
Ok((self, session))
}
}
#[allow(unused_macros)]
macro_rules! test_impl {
($create_repo:expr, enable_io, $(async fn $name:ident($repo:ident $(, $server:ident , $server_repo:ident)?) { $($body:tt)* })*) => {
$($crate::private::test_impl!($create_repo, $name, $repo $(, $server, $server_repo)?, { $($body)* });)*
};
($create_repo:expr, disable_io, $(async fn $name:ident($repo:ident $(, $server:ident , $server_repo:ident)?) { $($body:tt)* })*) => {};
($create_repo:expr, $name:ident, $repo:ident, { $($body:tt)* }) => {
#[test]
fn $name() {
::tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
#[allow(unused_variables)]
let $repo = $create_repo({
let mod_name = ::std::module_path!();
let test_name = ::std::stringify!($name);
format!("{mod_name}::{test_name}")
}).await;
let test_future = async { $($body)* };
use futures::FutureExt;
let timeout_future = ::tokio::time::sleep(::std::time::Duration::from_secs(10));
futures::select! {
_ = test_future.fuse() => {},
_ = timeout_future.fuse() => {
panic!("test timed out");
},
}
})
}
};
($create_repo:expr, $name:ident, $repo:ident, $server:ident, $server_repo:ident, { $($body:tt)* }) => {
#[test]
fn $name() {
::tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
#[allow(unused_variables)]
let $repo = $create_repo({
let mod_name = ::std::module_path!();
let test_name = ::std::stringify!($name);
format!("{mod_name}::{test_name}")
}).await;
#[allow(unused_variables, unused_mut)]
let (mut $server, $server_repo) = async {
let mod_name = ::std::module_path!();
let test_name = ::std::stringify!($name);
let repo_path = ::std::env::temp_dir()
.join("gitbutler-tests")
.join("git")
.join("remote")
.join(test_name)
.to_string_lossy()
.into_owned();
let repo = $crate::backend::git2::Repository::<$crate::backend::git2::tokio::TokioThreadedResource>::open_or_init_bare(repo_path.clone());
let server = $crate::private::TestSshServer::new(repo_path);
(server, repo)
}.await;
let test_future = async { $($body)* };
use futures::FutureExt;
let timeout_future = ::tokio::time::sleep(::std::time::Duration::from_secs(10));
futures::select! {
_ = test_future.fuse() => {},
_ = timeout_future.fuse() => {
panic!("test timed out");
},
}
})
}
};
}
pub(crate) use test_impl;

View File

@ -23,13 +23,10 @@
//!
//! This hampers certain use cases, such as implementing
//! [`cli::GitExecutor`] for e.g. remote connections.
#![cfg_attr(not(feature = "std"), no_std)] // must be first
#![feature(error_in_core)]
#![deny(missing_docs, unsafe_code)]
#![allow(async_fn_in_trait)]
#[cfg(not(feature = "std"))]
extern crate alloc;
#![cfg_attr(test, feature(async_closure))]
#![feature(impl_trait_in_assoc_type)]
#[cfg(test)]
mod integration_tests;
@ -42,8 +39,6 @@ pub mod ops;
mod refspec;
mod repository;
pub(crate) mod prelude;
#[cfg(feature = "cli")]
pub use backend::cli;
#[cfg(feature = "git2")]
@ -51,5 +46,5 @@ pub use backend::git2;
pub use self::{
refspec::{Error as RefSpecError, RefSpec},
repository::{Authorization, ConfigScope, Repository},
repository::{Authorization, ConfigScope, Error, Repository},
};

View File

@ -5,14 +5,13 @@
//! into more complex operations, without caring about the
//! underlying implementation.
#[allow(unused_imports)]
use crate::prelude::*;
use crate::{ConfigScope, Repository};
/// Returns whether or not the repository has GitButler's
/// utmost discretion enabled.
pub async fn has_utmost_discretion<R: Repository>(repo: &R) -> Result<bool, R::Error> {
pub async fn has_utmost_discretion<R: Repository>(
repo: &R,
) -> Result<bool, crate::Error<R::Error>> {
let config = repo
.config_get("gitbutler.utmostDiscretion", ConfigScope::default())
.await?;
@ -21,7 +20,10 @@ pub async fn has_utmost_discretion<R: Repository>(repo: &R) -> Result<bool, R::E
}
/// Sets whether or not the repository has GitButler's utmost discretion.
pub async fn set_utmost_discretion<R: Repository>(repo: &R, value: bool) -> Result<(), R::Error> {
pub async fn set_utmost_discretion<R: Repository>(
repo: &R,
value: bool,
) -> Result<(), crate::Error<R::Error>> {
repo.config_set(
"gitbutler.utmostDiscretion",
if value { "1" } else { "0" },

View File

@ -1,11 +0,0 @@
#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
pub use alloc::{
string::{String, ToString},
vec,
vec::Vec,
};
#[cfg(feature = "std")]
#[allow(unused_imports)]
pub use std::collections::BTreeMap;

View File

@ -1,8 +1,7 @@
use core::fmt;
/// An error that can occur while parsing a refspec from a string.
#[derive(Debug, PartialEq, Eq, Clone)]
#[cfg_attr(feature = "std", derive(thiserror::Error))]
#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
pub enum Error {
/// Encountered an unexpected character when parsing a [`RefSpec`] from a string.
#[error("unexpected character {0:?} (offset {1})")]
@ -49,10 +48,10 @@ impl RefSpec {
let mut offset = 0;
let s = if s.starts_with('+') {
let s = if let Some(stripped) = s.strip_prefix('+') {
refspec.update_non_fastforward = true;
offset += 1;
&s[1..]
stripped
} else {
s
};

View File

@ -1,5 +1,35 @@
#[allow(unused_imports)]
use crate::prelude::*;
use crate::RefSpec;
/// A backend-agnostic operation error.
#[derive(Debug, thiserror::Error)]
pub enum Error<BE: std::error::Error + core::fmt::Debug + Send + Sync + 'static> {
/// An otherwise backend-specific error that occurred and was not
/// directly related to the inputs or repository state related to
/// the operation, and instead occurred as a result of the backend
/// executing the operation itself.
#[error("backend error: {0}")]
Backend(#[from] BE),
/// The given refspec was not found.
/// Usually returned by a push or fetch operation.
#[error("a ref-spec was not found: {0}")]
RefNotFound(String),
/// An authorized operation was attempted, but the authorization
/// credentials were rejected by the remote (or further credentials
/// were required).
///
/// The inner error is the backend-specific error that may provide
/// more context.
#[error("authorization failed: {0}")]
AuthorizationFailed(BE),
/// An operation interacting with a remote by name failed to find
/// the remote.
#[error("no such remote: {0}")]
NoSuchRemote(String, #[source] BE),
/// An operation that expected a remote not to exist found that
/// the remote already existed.
#[error("remote already exists: {0}")]
RemoteExists(String, #[source] BE),
}
/// The scope from/to which a configuration value is read/written.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -38,7 +68,7 @@ pub enum ConfigScope {
/// A handle to an open Git repository.
pub trait Repository {
/// The type of error returned by this repository.
type Error: core::error::Error + core::fmt::Debug + Send + Sync + 'static;
type Error: std::error::Error + core::fmt::Debug + Send + Sync + 'static;
/// Reads a configuration value.
///
@ -47,7 +77,7 @@ pub trait Repository {
&self,
key: &str,
scope: ConfigScope,
) -> Result<Option<String>, Self::Error>;
) -> Result<Option<String>, Error<Self::Error>>;
/// Writes a configuration value.
///
@ -57,7 +87,34 @@ pub trait Repository {
key: &str,
value: &str,
scope: ConfigScope,
) -> Result<(), Self::Error>;
) -> Result<(), Error<Self::Error>>;
/// Fetchs the given refspec from the given remote.
///
/// This is an authorized operation; the given authorization
/// credentials will be used to authenticate with the remote.
async fn fetch(
&self,
remote: &str,
refspec: RefSpec,
authorization: &Authorization,
) -> Result<(), Error<Self::Error>>;
/// Sets the URI for a remote.
/// If the remote does not exist, it will be created.
/// If the remote already exists, [`Error::RemoteExists`] will be returned.
async fn create_remote(&self, remote: &str, uri: &str) -> Result<(), Error<Self::Error>>;
/// Creates a remote with the given URI, or updates the URI
/// if the remote already exists.
async fn create_or_update_remote(
&self,
remote: &str,
uri: &str,
) -> Result<(), Error<Self::Error>>;
/// Gets the URI for a remote.
async fn remote(&self, remote: &str) -> Result<String, Error<Self::Error>>;
}
/// Provides authentication credentials when performing
@ -68,17 +125,25 @@ pub enum Authorization {
/// default authorization mechanism, if any.
#[default]
Auto,
/// Performs HTTP(S) Basic authentication with a username
/// and password.
/// Performs HTTP(S) Basic authentication with a username and password.
///
/// Note that certain remotes may use this mechanism
/// for passing tokens as well; consult the respective
/// remote's documentation for what information to supply.
/// In the case of an SSH remote, the username is ignored. The username is
/// only used for HTTP(S) remotes, and in such cases, if username is `None`
/// and the remote requests for it, the operation will fail.
///
/// In order for HTTP(S) remotes to work with a `None` username or password,
/// the remote URI must include the basic auth credentials in the URI itself
/// (e.g. `https://[user]:[pass]@host/path`). Otherwise, the operation will
/// fail.
///
/// Note that certain remotes may use this mechanism for passing tokens as
/// well; consult the respective remote's documentation for what information
/// to supply.
Basic {
/// The username to use for authentication.
username: String,
username: Option<String>,
/// The password to use for authentication.
password: String,
password: Option<String>,
},
/// Specifies a set of credentials for logging in with SSH.
Ssh {