summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.cargo_vcs_info.json6
-rw-r--r--.github/workflows/rust.yml22
-rw-r--r--.gitignore2
-rw-r--r--CHANGELOG53
-rw-r--r--Cargo.lock785
-rw-r--r--Cargo.toml60
-rw-r--r--Cargo.toml.orig29
-rw-r--r--LICENSE.md24
-rw-r--r--README.md100
-rw-r--r--README.tpl16
-rw-r--r--examples/custom-prompt-clone.rs139
-rw-r--r--examples/git.rs146
-rw-r--r--src/base64_decode.rs110
-rw-r--r--src/default_prompt.rs183
-rw-r--r--src/lib.rs719
-rw-r--r--src/prompter.rs65
-rw-r--r--src/ssh_key.rs156
17 files changed, 2615 insertions, 0 deletions
diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json
new file mode 100644
index 0000000..dd15326
--- /dev/null
+++ b/.cargo_vcs_info.json
@@ -0,0 +1,6 @@
+{
+ "git": {
+ "sha1": "0f916a0b5a375b481a63e795875388f30add5390"
+ },
+ "path_in_vcs": ""
+} \ No newline at end of file
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
new file mode 100644
index 0000000..31000a2
--- /dev/null
+++ b/.github/workflows/rust.yml
@@ -0,0 +1,22 @@
+name: Rust
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Build
+ run: cargo build --verbose
+ - name: Run tests
+ run: cargo test --verbose
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4fffb2f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+/Cargo.lock
diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
index 0000000..3e68cc3
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,53 @@
+# Version 0.5.5 - 2024-09-15
+- [add][minor] Add support for `git2` version `0.19`.
+
+# Version 0.5.4 - 2024-03-15
+- [add][minor] Add the `GitAuthenticator::download()` convenience function.
+
+# Version 0.5.3 - 2023-10-08
+- [add][minor] Add support for customizing user prompts with `GitAuthenticator::set_prompter()`.
+
+# Version 0.5.2 - 2023-09-09
+- [change][patch] Fix typo and formatting of nested list in documentation.
+
+# Version 0.5.1 - 2023-09-09
+- [change][patch] Improve library level documentation and README.
+
+# Version 0.5.0 - 2023-09-06
+- [change][major] Rename `GitAuthenticator::clone()` to `clone_repo()` to avoid conflict with the `Clone` trait.
+
+# Version 0.4.1 - 2023-09-06
+- [add][minor] Add support for `git2` version `0.18`.
+
+# Version 0.4.0 - 2023-08-09
+- [change][major] Accept any `impl Into<PathBuf>` in `GitAuthenticator::add_ssh_key_from_file()`.
+
+# Version 0.3.3 - 2023-08-09
+- [add][minor] Support `git2` versions `0.14`, `0.15`, `0.16` and `0.17`.
+
+# Version 0.3.2 - 2023-08-09
+- [change][patch] Document that the `askpass` helper will be used for prompts, if available.
+
+# Version 0.3.1 - 2023-08-09
+- [add][minor] Add support for `askpass` helpers.
+
+# Version 0.3.0 - 2023-08-09
+- [change][major] Add optional password parameter to `GitAuthenticator::add_ssh_key_file()`.
+- [add][minor] Add option to prompt for the password of encrypted SSH key files.
+
+# Version 0.2.0 - 2023-08-08
+- [remove][major] Remove `GitAuthenticator::run_operation()`.
+- [change][major] Support only one username per domain name.
+- [change][major] Support only one set of plaintext credentials per domain name.
+- [add][minor] Add `GitAuthenticator::credentials()` to get the credentials callback.
+- [add][minor] Add `GitAuthenticator::clone()`.
+- [add][minor] Add `GitAuthenticator::fetch()`.
+- [add][minor] Add `GitAuthenticator::push()`.
+- [add][minor] Add optional support for the `log` crate.
+- [fix][patch] Bump minimum `terminal-prompt` version to `0.2.2`.
+
+# Version 0.1.1 - 2023-08-07
+- [patch][change] Fix examples and `README.md` for updated crate name.
+
+# Version 0.1.0 - 2023-08-07
+- [minor][add] Add `GitAuthenticator` struct for authentication with `git2`.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..1d30abd
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,785 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "assert2"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d31fea2b6e18dfe892863c3a0a68f9e005b0195565f3d55b8612946ebca789cc"
+dependencies = [
+ "assert2-macros",
+ "diff",
+ "is-terminal",
+ "yansi",
+]
+
+[[package]]
+name = "assert2-macros"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c1ac052c642f6d94e4be0b33028b346b7ab809ea5432b584eb8859f12f7ad2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+]
+
+[[package]]
+name = "auth-git2"
+version = "0.5.5"
+dependencies = [
+ "assert2",
+ "clap",
+ "dirs",
+ "env_logger",
+ "git2",
+ "log",
+ "terminal-prompt",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
+[[package]]
+name = "cc"
+version = "1.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[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 = "env_logger"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "git2"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "openssl-probe",
+ "openssl-sys",
+ "url",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "jobserver"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.158"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
+
+[[package]]
+name = "libgit2-sys"
+version = "0.17.0+1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
+dependencies = [
+ "cc",
+ "libc",
+ "libssh2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "terminal-prompt"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "572818b3472910acbd5dff46a3413715c18e934b071ab2ba464a7b2c2af16376"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..b8e3ccb
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,60 @@
+# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
+#
+# When uploading crates to the registry Cargo will automatically
+# "normalize" Cargo.toml files for maximal compatibility
+# with all versions of Cargo and also rewrite `path` dependencies
+# to registry (e.g., crates.io) dependencies.
+#
+# If you are reading this file be aware that the original Cargo.toml
+# will likely look very different (and much more reasonable).
+# See Cargo.toml.orig for the original contents.
+
+[package]
+edition = "2021"
+name = "auth-git2"
+version = "0.5.5"
+authors = ["Maarten de Vries <maarten@de-vri.es>"]
+publish = ["crates-io"]
+description = "Authentication for `git2`"
+documentation = "https://docs.rs/auth-git2"
+readme = "README.md"
+keywords = [
+ "git",
+ "auth",
+ "credentials",
+ "git2",
+ "authentication",
+]
+categories = ["authentication"]
+license = "BSD-2-Clause"
+repository = "https://github.com/de-vri-es/auth-git2-rs"
+
+[dependencies.dirs]
+version = "5.0.1"
+
+[dependencies.git2]
+version = ">0.14, <20.0"
+default-features = false
+
+[dependencies.log]
+version = "0.4.19"
+optional = true
+
+[dependencies.terminal-prompt]
+version = "0.2.2"
+
+[dev-dependencies.assert2]
+version = "0.3.11"
+
+[dev-dependencies.clap]
+version = "4.3.21"
+features = ["derive"]
+
+[dev-dependencies.env_logger]
+version = "0.10.0"
+
+[dev-dependencies.git2]
+version = ">=0.14, <18.0"
+
+[features]
+log = ["dep:log"]
diff --git a/Cargo.toml.orig b/Cargo.toml.orig
new file mode 100644
index 0000000..796b1bb
--- /dev/null
+++ b/Cargo.toml.orig
@@ -0,0 +1,29 @@
+[package]
+name = "auth-git2"
+version = "0.5.5"
+description = "Authentication for `git2`"
+license = "BSD-2-Clause"
+authors = ["Maarten de Vries <maarten@de-vri.es>"]
+repository = "https://github.com/de-vri-es/auth-git2-rs"
+documentation = "https://docs.rs/auth-git2"
+keywords = ["git", "auth", "credentials", "git2", "authentication"]
+categories = ["authentication"]
+
+edition = "2021"
+publish = ["crates-io"]
+
+[features]
+log = ["dep:log"]
+
+[dependencies]
+dirs = "5.0.1"
+git2 = { version = ">0.14, <20.0", default-features = false }
+log = { version = "0.4.19", optional = true }
+terminal-prompt = "0.2.2"
+
+[dev-dependencies]
+assert2 = "0.3.11"
+auth-git2 = { path = ".", features = ["log"] }
+clap = { version = "4.3.21", features = ["derive"] }
+env_logger = "0.10.0"
+git2 = ">=0.14, <18.0"
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..df9bcf5
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,24 @@
+BSD 2-Clause License
+
+Copyright (c) 2023, Maarten de Vries <maarten@de-vri.es>
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b6c138f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# auth-git2
+
+Easy authentication for [`git2`].
+
+Authentication with [`git2`] can be quite difficult to implement correctly.
+This crate aims to make it easy.
+
+## Features
+
+* Has a small dependency tree.
+* Can query the SSH agent for private key authentication.
+* Can get SSH keys from files.
+* Can prompt the user for passwords for encrypted SSH keys.
+ * Only supported for OpenSSH private keys.
+* Can query the git credential helper for usernames and passwords.
+* Can use pre-provided plain usernames and passwords.
+* Can prompt the user for credentials as a last resort.
+* Allows you to fully customize all user prompts.
+
+The default user prompts will:
+* Use the git `askpass` helper if it is configured.
+* Fall back to prompting the user on the terminal if there is no `askpass` program configured.
+* Skip the prompt if there is also no terminal available for the process.
+
+## Creating an authenticator and enabling authentication mechanisms
+
+You can create use [`GitAuthenticator::new()`] (or [`default()`][`GitAuthenticator::default()`]) to create a ready-to-use authenticator.
+Using one of these constructors will enable all supported authentication mechanisms.
+You can still add more private key files from non-default locations to try if desired.
+
+You can also use [`GitAuthenticator::new_empty()`] to create an authenticator without any authentication mechanism enabled.
+Then you can selectively enable authentication mechanisms and add custom private key files.
+
+## Using the authenticator
+
+For the most flexibility, you can get a [`git2::Credentials`] callback using the [`GitAuthenticator::credentials()`] function.
+You can use it with any git operation that requires authentication.
+Doing this gives you full control to set other options and callbacks for the git operation.
+
+If you don't need to set other options or callbacks, you can also use the convenience functions on [`GitAuthenticator`].
+They wrap git operations with the credentials callback set:
+
+* [`GitAuthenticator::clone_repo()`]
+* [`GitAuthenticator::fetch()`]
+* [`GitAuthenticator::download()`]
+* [`GitAuthenticator::push()`]
+
+## Customizing user prompts
+
+All user prompts can be fully customized by calling [`GitAuthenticator::set_prompter()`].
+This allows you to override the way that the user is prompted for credentials or passphrases.
+
+If you have a fancy user interface, you can use a custom prompter to integrate the prompts with your user interface.
+
+## Example: Clone a repository
+
+```rust
+use auth_git2::GitAuthenticator;
+use std::path::Path;
+
+let url = "https://github.com/de-vri-es/auth-git2-rs";
+let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
+
+let auth = GitAuthenticator::default();
+let mut repo = auth.clone_repo(url, into);
+```
+
+## Example: Clone a repository with full control over fetch options
+
+```rust
+use auth_git2::GitAuthenticator;
+use std::path::Path;
+
+let auth = GitAuthenticator::default();
+let git_config = git2::Config::open_default()?;
+let mut repo_builder = git2::build::RepoBuilder::new();
+let mut fetch_options = git2::FetchOptions::new();
+let mut remote_callbacks = git2::RemoteCallbacks::new();
+
+remote_callbacks.credentials(auth.credentials(&git_config));
+fetch_options.remote_callbacks(remote_callbacks);
+repo_builder.fetch_options(fetch_options);
+
+let url = "https://github.com/de-vri-es/auth-git2-rs";
+let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
+let mut repo = repo_builder.clone(url, into);
+```
+
+[`git2`]: https://docs.rs/git2
+[`GitAuthenticator`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html
+[`GitAuthenticator::new()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.new
+[`GitAuthenticator::default()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.default
+[`GitAuthenticator::new_empty()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.new_empty
+[`git2::Credentials`]: https://docs.rs/git2/latest/git2/type.Credentials.html
+[`GitAuthenticator::credentials()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.credentials
+[`GitAuthenticator::clone_repo()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.clone_repo
+[`GitAuthenticator::fetch()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.fetch
+[`GitAuthenticator::push()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.push
+[`GitAuthenticator::download()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.download
+[`GitAuthenticator::set_prompter()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.set_prompter
diff --git a/README.tpl b/README.tpl
new file mode 100644
index 0000000..12e0157
--- /dev/null
+++ b/README.tpl
@@ -0,0 +1,16 @@
+# {{crate}}
+
+{{readme}}
+
+[`git2`]: https://docs.rs/git2
+[`GitAuthenticator`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html
+[`GitAuthenticator::new()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.new
+[`GitAuthenticator::default()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.default
+[`GitAuthenticator::new_empty()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.new_empty
+[`git2::Credentials`]: https://docs.rs/git2/latest/git2/type.Credentials.html
+[`GitAuthenticator::credentials()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.credentials
+[`GitAuthenticator::clone_repo()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.clone_repo
+[`GitAuthenticator::fetch()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.fetch
+[`GitAuthenticator::push()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.push
+[`GitAuthenticator::download()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.download
+[`GitAuthenticator::set_prompter()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.set_prompter
diff --git a/examples/custom-prompt-clone.rs b/examples/custom-prompt-clone.rs
new file mode 100644
index 0000000..b06cff7
--- /dev/null
+++ b/examples/custom-prompt-clone.rs
@@ -0,0 +1,139 @@
+use std::path::{Path, PathBuf};
+
+#[derive(Copy, Clone)]
+struct YadPrompter;
+
+impl auth_git2::Prompter for YadPrompter {
+ fn prompt_username_password(&mut self, url: &str, _git_config: &git2::Config) -> Option<(String, String)> {
+ let mut items = yad_prompt(
+ "Git authentication",
+ &format!("Authentication required for {url}"),
+ &["Username", "Password:H"],
+ ).ok()?.into_iter();
+ let username = items.next()?;
+ let password = items.next()?;
+ Some((username, password))
+ }
+
+ fn prompt_password(&mut self, username: &str, url: &str, _git_config: &git2::Config) -> Option<String> {
+ let mut items = yad_prompt(
+ "Git authentication",
+ &format!("Authentication required for {url}"),
+ &[&format!("Username: {username}:LBL"), "Password:H"],
+ ).ok()?.into_iter();
+ let password = items.next()?;
+ Some(password)
+ }
+
+ fn prompt_ssh_key_passphrase(&mut self, private_key_path: &std::path::Path, _git_config: &git2::Config) -> Option<String> {
+ let mut items = yad_prompt(
+ "Git authentication",
+ &format!("Passphrase required for {}", private_key_path.display()),
+ &["Passphrase:H"],
+ ).ok()?.into_iter();
+ let passphrase = items.next()?;
+ Some(passphrase)
+ }
+}
+
+fn yad_prompt(title: &str, text: &str, fields: &[&str]) -> Result<Vec<String>, ()> {
+ let mut command = std::process::Command::new("yad");
+ command
+ .arg("--title")
+ .arg(title)
+ .arg("--text")
+ .arg(text)
+ .arg("--form")
+ .arg("--separator=\n");
+ for field in fields {
+ command.arg("--field");
+ command.arg(field);
+ }
+
+ let output = command
+ .stderr(std::process::Stdio::inherit())
+ .output()
+ .map_err(|e| log::error!("Failed to run `yad`: {e}"))?;
+
+ if !output.status.success() {
+ log::debug!("yad exited with {}", output.status);
+ return Err(());
+ }
+
+ let output = String::from_utf8(output.stdout)
+ .map_err(|_| log::warn!("Invalid UTF-8 in response from yad"))?;
+
+ let mut items: Vec<_> = output.splitn(fields.len() + 1, '\n')
+ .take(fields.len())
+ .map(|x| x.to_owned())
+ .collect();
+ if let Some(last) = items.pop() {
+ if !last.is_empty() {
+ items.push(last)
+ }
+ }
+
+ if items.len() != fields.len() {
+ log::error!("asked yad for {} values but got only {}", fields.len(), items.len());
+ Err(())
+ } else {
+ Ok(items)
+ }
+}
+
+#[derive(clap::Parser)]
+struct Options {
+ /// Show more verbose statement.
+ #[clap(long, short)]
+ #[clap(global = true)]
+ #[clap(action = clap::ArgAction::Count)]
+ verbose: u8,
+
+ /// The URL of the repository to clone.
+ #[clap(value_name = "URL")]
+ repo: String,
+
+ /// The path where to clone the repository.
+ #[clap(value_name = "PATH")]
+ local_path: Option<PathBuf>,
+}
+
+fn main() {
+ if let Err(()) = do_main(clap::Parser::parse()) {
+ std::process::exit(1);
+ }
+}
+
+fn log_level(verbose: u8) -> log::LevelFilter {
+ match verbose {
+ 0 => log::LevelFilter::Info,
+ 1 => log::LevelFilter::Debug,
+ 2.. => log::LevelFilter::Trace,
+ }
+}
+
+fn do_main(options: Options) -> Result<(), ()> {
+ let log_level = log_level(options.verbose);
+ env_logger::builder()
+ .parse_default_env()
+ .filter_module(module_path!(), log_level)
+ .filter_module("auth_git2", log_level)
+ .init();
+
+ let local_path = options.local_path.as_deref()
+ .unwrap_or_else(|| Path::new(repo_name_from_url(&options.repo)));
+
+ log::info!("Cloning {} into {}", options.repo, local_path.display());
+
+ let auth = auth_git2::GitAuthenticator::default()
+ .set_prompter(YadPrompter);
+ auth.clone_repo(&options.repo, local_path)
+ .map_err(|e| log::error!("Failed to clone {}: {}", options.repo, e))?;
+ Ok(())
+}
+
+fn repo_name_from_url(url: &str) -> &str {
+ url.rsplit_once('/')
+ .map(|(_head, tail)| tail)
+ .unwrap_or(url)
+}
diff --git a/examples/git.rs b/examples/git.rs
new file mode 100644
index 0000000..f3e613c
--- /dev/null
+++ b/examples/git.rs
@@ -0,0 +1,146 @@
+use std::path::{Path, PathBuf};
+
+#[derive(clap::Parser)]
+struct Options {
+ /// Show more verbose statement.
+ #[clap(long, short)]
+ #[clap(global = true)]
+ #[clap(action = clap::ArgAction::Count)]
+ verbose: u8,
+
+ /// The subcommand.
+ #[clap(subcommand)]
+ command: Command,
+}
+
+#[derive(clap::Subcommand)]
+enum Command {
+ Clone(CloneCommand),
+ Fetch(FetchCommand),
+ Push(PushCommand),
+}
+
+/// Clone a repository.
+#[derive(clap::Parser)]
+struct CloneCommand {
+ /// The URL of the repository to clone.
+ #[clap(value_name = "URL")]
+ repo: String,
+
+ /// The path where to clone the repository.
+ #[clap(value_name = "PATH")]
+ local_path: Option<PathBuf>,
+}
+
+/// Fetch from a remote.
+#[derive(clap::Parser)]
+struct FetchCommand {
+ /// The repository to operate on.
+ #[clap(value_name = "PATH")]
+ #[clap(short = 'C', long)]
+ repo: PathBuf,
+
+ /// The repository to operate on.
+ #[clap(value_name = "REMOTE")]
+ remote: String,
+
+ /// The refs to fetch.
+ #[clap(trailing_var_arg = true)]
+ #[clap(required = true)]
+ refspec: Vec<String>,
+}
+
+/// Push to a remote.
+#[derive(clap::Parser)]
+struct PushCommand {
+ /// The repository to operate on.
+ #[clap(value_name = "PATH")]
+ #[clap(short = 'C', long)]
+ #[clap(default_value = ".")]
+ repo: PathBuf,
+
+ /// The repository to operate on.
+ #[clap(value_name = "REMOTE")]
+ remote: String,
+
+ /// The refs to fetch.
+ #[clap(trailing_var_arg = true)]
+ #[clap(required = true)]
+ refspec: Vec<String>,
+}
+
+fn main() {
+ if let Err(()) = do_main(clap::Parser::parse()) {
+ std::process::exit(1);
+ }
+}
+
+fn log_level(verbose: u8) -> log::LevelFilter {
+ match verbose {
+ 0 => log::LevelFilter::Info,
+ 1 => log::LevelFilter::Debug,
+ 2.. => log::LevelFilter::Trace,
+ }
+}
+
+fn do_main(options: Options) -> Result<(), ()> {
+ let log_level = log_level(options.verbose);
+ env_logger::builder()
+ .parse_default_env()
+ .filter_module(module_path!(), log_level)
+ .filter_module("auth_git2", log_level)
+ .init();
+
+ match options.command {
+ Command::Clone(command) => clone(command),
+ Command::Fetch(command) => fetch(command),
+ Command::Push(command) => push(command),
+ }
+}
+
+fn clone(command: CloneCommand) -> Result<(), ()> {
+ let local_path = command.local_path.as_deref()
+ .unwrap_or_else(|| Path::new(repo_name_from_url(&command.repo)));
+
+ log::info!("Cloning {} into {}", command.repo, local_path.display());
+
+ let auth = auth_git2::GitAuthenticator::default();
+ auth.clone_repo(&command.repo, local_path)
+ .map_err(|e| log::error!("Failed to clone {}: {}", command.repo, e))?;
+ Ok(())
+}
+
+fn fetch(command: FetchCommand) -> Result<(), ()> {
+ let repo = git2::Repository::open(&command.repo)
+ .map_err(|e| log::error!("Failed to open git repo at {}: {e}", command.repo.display()))?;
+
+ let refspecs: Vec<_> = command.refspec.iter().map(|x| x.as_str()).collect();
+
+ let auth = auth_git2::GitAuthenticator::default();
+ let mut remote = repo.find_remote(&command.remote)
+ .map_err(|e| log::error!("Failed to find remote {:?}: {e}", command.remote))?;
+ auth.fetch(&repo, &mut remote, &refspecs, None)
+ .map_err(|e| log::error!("Failed to fetch from remote {:?}: {e}", command.remote))?;
+ Ok(())
+}
+
+fn push(command: PushCommand) -> Result<(), ()> {
+ let repo = git2::Repository::open(&command.repo)
+ .map_err(|e| log::error!("Failed to open git repo at {}: {e}", command.repo.display()))?;
+
+ log::info!("Fetching {:?} from remote {:?}", command.refspec, command.remote);
+ let refspecs: Vec<_> = command.refspec.iter().map(|x| x.as_str()).collect();
+
+ let auth = auth_git2::GitAuthenticator::default();
+ let mut remote = repo.find_remote(&command.remote)
+ .map_err(|e| log::error!("Failed to find remote {:?}: {e}", command.remote))?;
+ auth.push(&repo, &mut remote, &refspecs,)
+ .map_err(|e| log::error!("Failed to push to remote {:?}: {e}", command.remote))?;
+ Ok(())
+}
+
+fn repo_name_from_url(url: &str) -> &str {
+ url.rsplit_once('/')
+ .map(|(_head, tail)| tail)
+ .unwrap_or(url)
+}
diff --git a/src/base64_decode.rs b/src/base64_decode.rs
new file mode 100644
index 0000000..de8147c
--- /dev/null
+++ b/src/base64_decode.rs
@@ -0,0 +1,110 @@
+/// An error that can occur during base64 decoding.
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum Error {
+ InvalidBase64Char(u8),
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidBase64Char(value) => write!(f, "Invalid base64 character: {:?}", char::from_u32(*value as u32).unwrap()),
+ }
+ }
+}
+
+/// Decode a base64 string.
+///
+/// Padding in the input is optional.
+pub fn base64_decode(input: &[u8]) -> Result<Vec<u8>, Error> {
+ let input = match input.iter().rposition(|&byte| byte != b'=' && !byte.is_ascii_whitespace()) {
+ Some(x) => &input[..=x],
+ None => return Ok(Vec::new()),
+ };
+
+ let mut output = Vec::with_capacity((input.len() + 3) / 4 * 3);
+ let mut decoder = Base64Decoder::new();
+
+ for &byte in input {
+ if byte.is_ascii_whitespace() {
+ continue;
+ }
+ if let Some(byte) = decoder.feed(byte)? {
+ output.push(byte);
+ }
+ }
+
+ Ok(output)
+}
+
+/// Get the 6 bit value for a base64 character.
+fn base64_value(byte: u8) -> Result<u8, Error> {
+ match byte {
+ b'A'..=b'Z' => Ok(byte - b'A'),
+ b'a'..=b'z' => Ok(byte - b'a' + 26),
+ b'0'..=b'9' => Ok(byte - b'0' + 52),
+ b'+' => Ok(62),
+ b'/' => Ok(63),
+ byte => Err(Error::InvalidBase64Char(byte)),
+ }
+}
+
+/// Decoder for base64 data.
+struct Base64Decoder {
+ /// The current buffer.
+ buffer: u16,
+
+ /// The number of valid bits in the buffer.
+ valid_bits: u8,
+}
+
+impl Base64Decoder {
+ /// Create a new base64 decoder.
+ fn new() -> Self {
+ Self {
+ buffer: 0,
+ valid_bits: 0,
+ }
+ }
+
+ /// Feed a base64 character to the decoder.
+ ///
+ /// Returns `Ok(Some(u8))` if a new character is fully decoded.
+ /// Returns `Ok(None)` if there is no new character available yet.
+ fn feed(&mut self, byte: u8) -> Result<Option<u8>, Error> {
+ debug_assert!(self.valid_bits < 8);
+ // Paste the new 6 bit value at the least significant position in the buffer.
+ self.buffer |= (base64_value(byte)? as u16) << (10 - self.valid_bits);
+ // Bump the number of valid bits.
+ self.valid_bits += 6;
+ // Consume the most significant byte if it is complete.
+ Ok(self.consume_buffer_front())
+ }
+
+ /// Consume the first character in the buffer.
+ fn consume_buffer_front(&mut self) -> Option<u8> {
+ if self.valid_bits >= 8 {
+ let value = self.buffer >> 8 & 0xFF;
+ self.buffer <<= 8;
+ self.valid_bits -= 8;
+ Some(value as u8)
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use assert2::assert;
+
+ #[test]
+ fn test_decode_base64() {
+ assert!(let Ok(b"0") = base64_decode(b"MA").as_deref());
+ assert!(let Ok(b"0") = base64_decode(b"MA=").as_deref());
+ assert!(let Ok(b"0") = base64_decode(b"MA==").as_deref());
+ assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw").as_deref());
+ assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw=").as_deref());
+ assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw==").as_deref());
+ }
+}
diff --git a/src/default_prompt.rs b/src/default_prompt.rs
new file mode 100644
index 0000000..0b1641c
--- /dev/null
+++ b/src/default_prompt.rs
@@ -0,0 +1,183 @@
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+#[cfg(feature = "log")]
+use crate::log::*;
+
+#[derive(Copy, Clone)]
+pub(crate) struct DefaultPrompter;
+
+impl crate::Prompter for DefaultPrompter {
+ fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)> {
+ prompt_username_password(url, git_config)
+ .map_err(|e| log_error("username and password", &e))
+ .ok()
+ }
+
+ fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String> {
+ prompt_password(username, url, git_config)
+ .map_err(|e| log_error("password", &e))
+ .ok()
+ }
+
+ fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String> {
+ prompt_ssh_key_passphrase(private_key_path, git_config)
+ .map_err(|e| log_error("SSH key passphrase", &e))
+ .ok()
+ }
+}
+
+fn log_error(kind: &str, error: &Error) {
+ warn!("Failed to prompt the user for {kind}: {error}");
+ if let Error::AskpassExitStatus(error) = error {
+ if let Some(extra_message) = error.extra_message() {
+ for line in extra_message.lines() {
+ warn!("askpass: {line}");
+ }
+ }
+ }
+}
+
+/// Error that can occur when prompting for a password.
+pub enum Error {
+ /// Failed to run the askpass command.
+ AskpassCommand(std::io::Error),
+
+ /// Askpass command exitted with a non-zero error code.
+ AskpassExitStatus(AskpassExitStatusError),
+
+ /// Password contains invalid UTF-8.
+ InvalidUtf8(std::string::FromUtf8Error),
+
+ /// Failed to open a handle to the main terminal of the process.
+ OpenTerminal(std::io::Error),
+
+ /// Failed to read/write to the terminal.
+ ReadWriteTerminal(std::io::Error),
+}
+
+/// The askpass process exited with a non-zero exit code.
+pub struct AskpassExitStatusError {
+ /// The exit status of the askpass process.
+ pub status: std::process::ExitStatus,
+
+ /// The standard error of the askpass process.
+ pub stderr: Result<String, std::string::FromUtf8Error>,
+}
+
+impl AskpassExitStatusError {
+ /// Get the extra error message, if any.
+ ///
+ /// This will give the standard error of the askpass process if it exited with an error.
+ pub fn extra_message(&self) -> Option<&str> {
+ self.stderr.as_deref().ok()
+ }
+}
+
+/// Prompt the user for a username and password for a particular URL.
+///
+/// This uses the askpass helper if configured,
+/// and falls back to prompting on the terminal otherwise.
+fn prompt_username_password(url: &str, git_config: &git2::Config) -> Result<(String, String), Error> {
+ if let Some(askpass) = askpass_command(git_config) {
+ let username = askpass_prompt(&askpass, &format!("Username for {url}"))?;
+ let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
+ Ok((username, password))
+ } else {
+ let mut terminal = terminal_prompt::Terminal::open()
+ .map_err(Error::OpenTerminal)?;
+ writeln!(terminal, "Authentication needed for {url}")
+ .map_err(Error::ReadWriteTerminal)?;
+ let username = terminal.prompt("Username: ")
+ .map_err(Error::ReadWriteTerminal)?;
+ let password = terminal.prompt_sensitive("Password: ")
+ .map_err(Error::ReadWriteTerminal)?;
+ Ok((username, password))
+ }
+}
+
+/// Prompt the user for a password for a particular URL and username.
+///
+/// This uses the askpass helper if configured,
+/// and falls back to prompting on the terminal otherwise.
+fn prompt_password(_username: &str, url: &str, git_config: &git2::Config) -> Result<String, Error> {
+ if let Some(askpass) = askpass_command(git_config) {
+ let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
+ Ok(password)
+ } else {
+ let mut terminal = terminal_prompt::Terminal::open()
+ .map_err(Error::OpenTerminal)?;
+ writeln!(terminal, "Authentication needed for {url}")
+ .map_err(Error::ReadWriteTerminal)?;
+ let password = terminal.prompt_sensitive("Password: ")
+ .map_err(Error::ReadWriteTerminal)?;
+ Ok(password)
+ }
+}
+
+/// Prompt the user for the password of an encrypted SSH key.
+///
+/// This uses the askpass helper if configured,
+/// and falls back to prompting on the terminal otherwise.
+fn prompt_ssh_key_passphrase(private_key_path: &Path, git_config: &git2::Config) -> Result<String, Error> {
+ if let Some(askpass) = askpass_command(git_config) {
+ askpass_prompt(&askpass, &format!("Password for {}", private_key_path.display()))
+ } else {
+ let mut terminal = terminal_prompt::Terminal::open()
+ .map_err(Error::OpenTerminal)?;
+ writeln!(terminal, "Password needed for {}", private_key_path.display())
+ .map_err(Error::ReadWriteTerminal)?;
+ terminal.prompt_sensitive("Password: ")
+ .map_err(Error::ReadWriteTerminal)
+ }
+}
+
+/// Get the configured askpass program, if any.
+fn askpass_command(git_config: &git2::Config) -> Option<PathBuf> {
+ if let Some(command) = std::env::var_os("GIT_ASKPASS") {
+ Some(command.into())
+ } else if let Ok(command) = git_config.get_path("core.askPass") {
+ return Some(command)
+ } else if let Some(command) = std::env::var_os("SSH_ASKPASS") {
+ return Some(command.into());
+ } else {
+ None
+ }
+}
+
+/// Prompt the user using the given askpass program.
+fn askpass_prompt(program: &Path, prompt: &str) -> Result<String, Error> {
+ let output = std::process::Command::new(program)
+ .arg(prompt)
+ .output()
+ .map_err(Error::AskpassCommand)?;
+ if output.status.success() {
+ let password = String::from_utf8(output.stdout)
+ .map_err(Error::InvalidUtf8)?;
+ Ok(password)
+ } else {
+ // Do not keep stdout, it could contain a password D:
+ Err(Error::AskpassExitStatus(AskpassExitStatusError {
+ status: output.status,
+ stderr: String::from_utf8(output.stderr),
+ }))
+ }
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::AskpassCommand(e) => write!(f, "Failed to run askpass command: {e}"),
+ Self::AskpassExitStatus(e) => write!(f, "{e}"),
+ Self::InvalidUtf8(_) => write!(f, "User response contains invalid UTF-8"),
+ Self::OpenTerminal(e) => write!(f, "Failed to open terminal: {e}"),
+ Self::ReadWriteTerminal(e) => write!(f, "Failed to read/write to terminal: {e}"),
+ }
+ }
+}
+
+impl std::fmt::Display for AskpassExitStatusError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Program exitted with {}", self.status)
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..20f91c0
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,719 @@
+//! Easy authentication for [`git2`].
+//!
+//! Authentication with [`git2`] can be quite difficult to implement correctly.
+//! This crate aims to make it easy.
+//!
+//! # Features
+//!
+//! * Has a small dependency tree.
+//! * Can query the SSH agent for private key authentication.
+//! * Can get SSH keys from files.
+//! * Can prompt the user for passwords for encrypted SSH keys.
+//! * Only supported for OpenSSH private keys.
+//! * Can query the git credential helper for usernames and passwords.
+//! * Can use pre-provided plain usernames and passwords.
+//! * Can prompt the user for credentials as a last resort.
+//! * Allows you to fully customize all user prompts.
+//!
+//! The default user prompts will:
+//! * Use the git `askpass` helper if it is configured.
+//! * Fall back to prompting the user on the terminal if there is no `askpass` program configured.
+//! * Skip the prompt if there is also no terminal available for the process.
+//!
+//! # Creating an authenticator and enabling authentication mechanisms
+//!
+//! You can create use [`GitAuthenticator::new()`] (or [`default()`][`GitAuthenticator::default()`]) to create a ready-to-use authenticator.
+//! Using one of these constructors will enable all supported authentication mechanisms.
+//! You can still add more private key files from non-default locations to try if desired.
+//!
+//! You can also use [`GitAuthenticator::new_empty()`] to create an authenticator without any authentication mechanism enabled.
+//! Then you can selectively enable authentication mechanisms and add custom private key files.
+//!
+//! # Using the authenticator
+//!
+//! For the most flexibility, you can get a [`git2::Credentials`] callback using the [`GitAuthenticator::credentials()`] function.
+//! You can use it with any git operation that requires authentication.
+//! Doing this gives you full control to set other options and callbacks for the git operation.
+//!
+//! If you don't need to set other options or callbacks, you can also use the convenience functions on [`GitAuthenticator`].
+//! They wrap git operations with the credentials callback set:
+//!
+//! * [`GitAuthenticator::clone_repo()`]
+//! * [`GitAuthenticator::fetch()`]
+//! * [`GitAuthenticator::download()`]
+//! * [`GitAuthenticator::push()`]
+//!
+//! # Customizing user prompts
+//!
+//! All user prompts can be fully customized by calling [`GitAuthenticator::set_prompter()`].
+//! This allows you to override the way that the user is prompted for credentials or passphrases.
+//!
+//! If you have a fancy user interface, you can use a custom prompter to integrate the prompts with your user interface.
+//!
+//! # Example: Clone a repository
+//!
+//! ```no_run
+//! # fn main() -> Result<(), git2::Error> {
+//! use auth_git2::GitAuthenticator;
+//! use std::path::Path;
+//!
+//! let url = "https://github.com/de-vri-es/auth-git2-rs";
+//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
+//!
+//! let auth = GitAuthenticator::default();
+//! let mut repo = auth.clone_repo(url, into);
+//! # let _ = repo;
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! # Example: Clone a repository with full control over fetch options
+//!
+//! ```no_run
+//! # fn main() -> Result<(), git2::Error> {
+//! use auth_git2::GitAuthenticator;
+//! use std::path::Path;
+//!
+//! let auth = GitAuthenticator::default();
+//! let git_config = git2::Config::open_default()?;
+//! let mut repo_builder = git2::build::RepoBuilder::new();
+//! let mut fetch_options = git2::FetchOptions::new();
+//! let mut remote_callbacks = git2::RemoteCallbacks::new();
+//!
+//! remote_callbacks.credentials(auth.credentials(&git_config));
+//! fetch_options.remote_callbacks(remote_callbacks);
+//! repo_builder.fetch_options(fetch_options);
+//!
+//! let url = "https://github.com/de-vri-es/auth-git2-rs";
+//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
+//! let mut repo = repo_builder.clone(url, into);
+//! # let _ = repo;
+//! # Ok(())
+//! # }
+//! ```
+
+#![warn(missing_docs)]
+
+use std::collections::BTreeMap;
+use std::path::{PathBuf, Path};
+
+#[cfg(feature = "log")]
+mod log {
+ pub use ::log::warn;
+ pub use ::log::debug;
+ pub use ::log::trace;
+}
+
+#[cfg(feature = "log")]
+use crate::log::*;
+
+#[cfg(not(feature = "log"))]
+#[macro_use]
+mod log {
+ macro_rules! warn {
+ ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
+ }
+
+ macro_rules! debug {
+ ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
+ }
+
+ macro_rules! trace {
+ ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
+ }
+}
+
+mod base64_decode;
+mod default_prompt;
+mod prompter;
+mod ssh_key;
+
+pub use prompter::Prompter;
+
+/// Configurable authenticator to use with [`git2`].
+#[derive(Clone)]
+pub struct GitAuthenticator {
+ /// Map of domain names to plaintext credentials.
+ plaintext_credentials: BTreeMap<String, PlaintextCredentials>,
+
+ /// Try getting username/password from the git credential helper.
+ try_cred_helper: bool,
+
+ /// Number of times to ask the user for a username/password on the terminal.
+ try_password_prompt: u32,
+
+ /// Map of domain names to usernames to try for SSH connections if no username was specified.
+ usernames: BTreeMap<String, String>,
+
+ /// Try to use the SSH agent to get a working SSH key.
+ try_ssh_agent: bool,
+
+ /// SSH keys to use from file.
+ ssh_keys: Vec<PrivateKeyFile>,
+
+ /// Prompt for passwords for encrypted SSH keys.
+ prompt_ssh_key_password: bool,
+
+ /// Custom prompter to use.
+ prompter: Box<dyn prompter::ClonePrompter>,
+}
+
+impl std::fmt::Debug for GitAuthenticator {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("GitAuthenticator")
+ .field("plaintext_credentials", &self.plaintext_credentials)
+ .field("try_cred_helper", &self.try_cred_helper)
+ .field("try_password_prompt", &self.try_password_prompt)
+ .field("usernames", &self.usernames)
+ .field("try_ssh_agent", &self.try_ssh_agent)
+ .field("ssh_keys", &self.ssh_keys)
+ .field("prompt_ssh_key_password", &self.prompt_ssh_key_password)
+ .finish()
+ }
+}
+
+impl Default for GitAuthenticator {
+ /// Create a new authenticator with all supported options enabled.
+ ///
+ /// This is the same as [`GitAuthenticator::new()`].
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl GitAuthenticator {
+ /// Create a new authenticator with all supported options enabled.
+ ///
+ /// This is equivalent to:
+ /// ```
+ /// # use auth_git2::GitAuthenticator;
+ /// GitAuthenticator::new_empty()
+ /// .try_cred_helper(true)
+ /// .try_password_prompt(3)
+ /// .add_default_username()
+ /// .try_ssh_agent(true)
+ /// .add_default_ssh_keys()
+ /// .prompt_ssh_key_password(true)
+ /// # ;
+ /// ```
+ pub fn new() -> Self {
+ Self::new_empty()
+ .try_cred_helper(true)
+ .try_password_prompt(3)
+ .add_default_username()
+ .try_ssh_agent(true)
+ .add_default_ssh_keys()
+ .prompt_ssh_key_password(true)
+ }
+
+ /// Create a new authenticator with all authentication options disabled.
+ pub fn new_empty() -> Self {
+ Self {
+ try_ssh_agent: false,
+ try_cred_helper: false,
+ plaintext_credentials: BTreeMap::new(),
+ try_password_prompt: 0,
+ usernames: BTreeMap::new(),
+ ssh_keys: Vec::new(),
+ prompt_ssh_key_password: false,
+ prompter: prompter::wrap_prompter(default_prompt::DefaultPrompter),
+ }
+ }
+
+ /// Set the username + password to use for a specific domain.
+ ///
+ /// Use the special value "*" for the domain name to add fallback credentials when there is no exact match for the domain.
+ pub fn add_plaintext_credentials(mut self, domain: impl Into<String>, username: impl Into<String>, password: impl Into<String>) -> Self {
+ let domain = domain.into();
+ let username = username.into();
+ let password = password.into();
+ self.plaintext_credentials.insert(domain, PlaintextCredentials {
+ username,
+ password,
+ });
+ self
+ }
+
+ /// Configure if the git credentials helper should be used.
+ ///
+ /// See the git documentation of the `credential.helper` configuration options for more details.
+ pub fn try_cred_helper(mut self, enable: bool) -> Self {
+ self.try_cred_helper = enable;
+ self
+ }
+
+ /// Configure the number of times we should prompt the user for a username/password.
+ ///
+ /// Setting this value to `0` disables password prompts.
+ ///
+ /// By default, if an `askpass` helper is configured, it will be used for the prompts.
+ /// Otherwise, the user will be prompted directly on the terminal of the current process.
+ /// If there is also no terminal available, the prompt is skipped.
+ ///
+ /// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
+ /// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
+ ///
+ /// You can override the prompt behaviour by calling [`Self::set_prompter()`].
+ pub fn try_password_prompt(mut self, max_count: u32) -> Self {
+ self.try_password_prompt = max_count;
+ self
+ }
+
+ /// Use a custom [`Prompter`] to prompt the user for credentials and passphrases.
+ ///
+ /// If you set a custom prompter,
+ /// the authenticator will no longer try to use the `askpass` helper or prompt the user on the terminal.
+ /// Instead, the provided prompter will be called.
+ ///
+ /// Note that prompts must still be enabled with [`Self::try_password_prompt()`] and [`Self::prompt_ssh_key_password()`].
+ /// If prompts are disabled, your custom prompter will not be called.
+ ///
+ /// You can use this function to integrate the prompts with your own user interface
+ /// or simply to tweak the way the user is prompted on the terminal.
+ ///
+ /// A unique clone of the prompter will be used for each [`git2::Credentials`] callback returned by [`Self::credentials()`].
+ pub fn set_prompter<P: Prompter + Clone + Send + 'static>(mut self, prompter: P) -> Self {
+ self.prompter = prompter::wrap_prompter(prompter);
+ self
+ }
+
+ /// Add a username to try for authentication for a specific domain.
+ ///
+ /// Some authentication mechanisms need a username, but not all valid git URLs specify one.
+ /// You can add one or more usernames to try in that situation.
+ ///
+ /// You can use the special domain name "*" to set a fallback username for domains that do not have a specific username set.
+ pub fn add_username(mut self, domain: impl Into<String>, username: impl Into<String>) -> Self {
+ let domain = domain.into();
+ let username = username.into();
+ self.usernames.insert(domain, username);
+ self
+ }
+
+ /// Add the default username to try.
+ ///
+ /// The default username if read from the `USER` or `USERNAME` environment variable.
+ pub fn add_default_username(self) -> Self {
+ if let Ok(username) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
+ self.add_username("*", username)
+ } else {
+ self
+ }
+ }
+
+ /// Configure if the SSH agent should be used for public key authentication.
+ pub fn try_ssh_agent(mut self, enable: bool) -> Self {
+ self.try_ssh_agent = enable;
+ self
+ }
+
+ /// Add a private key to use for public key authentication.
+ ///
+ /// The key will be read from disk by `git2`, so it must still exist when the authentication is performed.
+ ///
+ /// You can provide a password for decryption of the private key.
+ /// If no password is provided and the `Self::prompt_ssh_key_password()` is enabled,
+ /// the user will be prompted for the passphrase of encrypted keys.
+ /// Note that currently only the `OpenSSH` private key format is supported for detecting that a key is encrypted.
+ ///
+ /// A matching `.pub` file will also be read if it exists.
+ /// For example, if you add the private key `"foo/my_ssh_id"`,
+ /// then `"foo/my_ssh_id.pub"` will be used too, if it exists.
+ pub fn add_ssh_key_from_file(mut self, private_key: impl Into<PathBuf>, password: impl Into<Option<String>>) -> Self {
+ let private_key = private_key.into();
+ let public_key = get_pub_key_path(&private_key);
+ let password = password.into();
+ self.ssh_keys.push(PrivateKeyFile {
+ private_key,
+ public_key,
+ password,
+ });
+ self
+ }
+
+ /// Add all default SSH keys for public key authentication.
+ ///
+ /// This will add all of the following files, if they exist:
+ ///
+ /// * `"$HOME/.ssh/id_rsa"`
+ /// * `"$HOME/.ssh/id_ecdsa"`
+ /// * `"$HOME/.ssh/id_ecdsa_sk"`
+ /// * `"$HOME/.ssh/id_ed25519"`
+ /// * `"$HOME/.ssh/id_ed25519_sk"`
+ /// * `"$HOME/.ssh/id_dsa"`
+ pub fn add_default_ssh_keys(mut self) -> Self {
+ let ssh_dir = match dirs::home_dir() {
+ Some(x) => x.join(".ssh"),
+ None => return self,
+ };
+
+ let candidates = [
+ "id_rsa",
+ "id_ecdsa,",
+ "id_ecdsa_sk",
+ "id_ed25519",
+ "id_ed25519_sk",
+ "id_dsa",
+ ];
+
+ for candidate in candidates {
+ let private_key = ssh_dir.join(candidate);
+ if !private_key.is_file() {
+ continue;
+ }
+ self = self.add_ssh_key_from_file(private_key, None);
+ }
+
+ self
+ }
+
+ /// Prompt for passwords for encrypted SSH keys if needed.
+ ///
+ /// By default, if an `askpass` helper is configured, it will be used for the prompts.
+ /// Otherwise, the user will be prompted directly on the terminal of the current process.
+ /// If there is also no terminal available, the prompt is skipped.
+ ///
+ /// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
+ /// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
+ ///
+ /// You can override the prompt behaviour by calling [`Self::set_prompter()`].
+ pub fn prompt_ssh_key_password(mut self, enable: bool) -> Self {
+ self.prompt_ssh_key_password = enable;
+ self
+ }
+
+ /// Get the credentials callback to use for [`git2::Credentials`].
+ ///
+ /// # Example: Fetch from a remote with authentication
+ /// ```no_run
+ /// # fn foo(repo: &mut git2::Repository) -> Result<(), git2::Error> {
+ /// use auth_git2::GitAuthenticator;
+ ///
+ /// let auth = GitAuthenticator::default();
+ /// let git_config = repo.config()?;
+ /// let mut fetch_options = git2::FetchOptions::new();
+ /// let mut remote_callbacks = git2::RemoteCallbacks::new();
+ ///
+ /// remote_callbacks.credentials(auth.credentials(&git_config));
+ /// fetch_options.remote_callbacks(remote_callbacks);
+ ///
+ /// repo.find_remote("origin")?
+ /// .fetch(&["main"], Some(&mut fetch_options), None)?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn credentials<'a>(
+ &'a self,
+ git_config: &'a git2::Config,
+ ) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
+ make_credentials_callback(self, git_config)
+ }
+
+ /// Clone a repository using the git authenticator.
+ ///
+ /// If you need more control over the clone options,
+ /// use [`Self::credentials()`] with a [`git2::build::RepoBuilder`].
+ pub fn clone_repo(&self, url: impl AsRef<str>, into: impl AsRef<Path>) -> Result<git2::Repository, git2::Error> {
+ let url = url.as_ref();
+ let into = into.as_ref();
+
+ let git_config = git2::Config::open_default()?;
+ let mut repo_builder = git2::build::RepoBuilder::new();
+ let mut fetch_options = git2::FetchOptions::new();
+ let mut remote_callbacks = git2::RemoteCallbacks::new();
+
+ remote_callbacks.credentials(self.credentials(&git_config));
+ fetch_options.remote_callbacks(remote_callbacks);
+ repo_builder.fetch_options(fetch_options);
+
+ repo_builder.clone(url, into)
+ }
+
+
+ /// Fetch from a remote using the git authenticator.
+ ///
+ /// If you need more control over the fetch options,
+ /// use [`Self::credentials()`] with [`git2::Remote::fetch()`].
+ pub fn fetch(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str], reflog_msg: Option<&str>) -> Result<(), git2::Error> {
+ let git_config = repo.config()?;
+ let mut fetch_options = git2::FetchOptions::new();
+ let mut remote_callbacks = git2::RemoteCallbacks::new();
+
+ remote_callbacks.credentials(self.credentials(&git_config));
+ fetch_options.remote_callbacks(remote_callbacks);
+ remote.fetch(refspecs, Some(&mut fetch_options), reflog_msg)
+ }
+
+ /// Download and index the packfile from a remote using the git authenticator.
+ ///
+ /// If you need more control over the download options,
+ /// use [`Self::credentials()`] with [`git2::Remote::download()`].
+ ///
+ /// This function does not update the remote tracking branches.
+ /// Consider using [`Self::fetch()`] if that is what you want.
+ pub fn download(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
+ let git_config = repo.config()?;
+ let mut fetch_options = git2::FetchOptions::new();
+ let mut remote_callbacks = git2::RemoteCallbacks::new();
+
+ remote_callbacks.credentials(self.credentials(&git_config));
+ fetch_options.remote_callbacks(remote_callbacks);
+ remote.download(refspecs, Some(&mut fetch_options))
+ }
+
+ /// Push to a remote using the git authenticator.
+ ///
+ /// If you need more control over the push options,
+ /// use [`Self::credentials()`] with [`git2::Remote::push()`].
+ pub fn push(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
+ let git_config = repo.config()?;
+ let mut push_options = git2::PushOptions::new();
+ let mut remote_callbacks = git2::RemoteCallbacks::new();
+
+ remote_callbacks.credentials(self.credentials(&git_config));
+ push_options.remote_callbacks(remote_callbacks);
+
+ remote.push(refspecs, Some(&mut push_options))
+ }
+
+ /// Get the configured username for a URL.
+ fn get_username(&self, url: &str) -> Option<&str> {
+ if let Some(domain) = domain_from_url(url) {
+ if let Some(username) = self.usernames.get(domain) {
+ return Some(username);
+ }
+ }
+ self.usernames.get("*").map(|x| x.as_str())
+ }
+
+ /// Get the configured plaintext credentials for a URL.
+ fn get_plaintext_credentials(&self, url: &str) -> Option<&PlaintextCredentials> {
+ if let Some(domain) = domain_from_url(url) {
+ if let Some(credentials) = self.plaintext_credentials.get(domain) {
+ return Some(credentials);
+ }
+ }
+ self.plaintext_credentials.get("*")
+ }
+}
+
+fn make_credentials_callback<'a>(
+ authenticator: &'a GitAuthenticator,
+ git_config: &'a git2::Config,
+) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
+ let mut try_cred_helper = authenticator.try_cred_helper;
+ let mut try_password_prompt = authenticator.try_password_prompt;
+ let mut try_ssh_agent = authenticator.try_ssh_agent;
+ let mut ssh_keys = authenticator.ssh_keys.iter();
+ let mut prompter = authenticator.prompter.clone();
+
+ move |url: &str, username: Option<&str>, allowed: git2::CredentialType| {
+ trace!("credentials callback called with url: {url:?}, username: {username:?}, allowed_credentials: {allowed:?}");
+
+ // If git2 is asking for a username, we got an SSH url without username specified.
+ // After we supply a username, it will ask for the real credentials.
+ //
+ // Sadly, we can not switch usernames during an authentication session,
+ // so to try different usernames, we need to retry the git operation multiple times.
+ // If this happens, we'll bail and go into stage 2.
+ if allowed.contains(git2::CredentialType::USERNAME) {
+ if let Some(username) = authenticator.get_username(url) {
+ debug!("credentials_callback: returning username: {username:?}");
+ match git2::Cred::username(username) {
+ Ok(x) => return Ok(x),
+ Err(e) => {
+ debug!("credentials_callback: failed to wrap username: {e}");
+ return Err(e);
+ },
+ }
+ }
+ }
+
+ // Try public key authentication.
+ if allowed.contains(git2::CredentialType::SSH_KEY) {
+ if let Some(username) = username {
+ if try_ssh_agent {
+ try_ssh_agent = false;
+ debug!("credentials_callback: trying ssh_key_from_agent with username: {username:?}");
+ match git2::Cred::ssh_key_from_agent(username) {
+ Ok(x) => return Ok(x),
+ Err(e) => debug!("credentials_callback: failed to use SSH agent: {e}"),
+ }
+ }
+
+ #[allow(clippy::while_let_on_iterator)] // Incorrect lint: we're not consuming the iterator.
+ while let Some(key) = ssh_keys.next() {
+ debug!("credentials_callback: trying ssh key, username: {username:?}, private key: {:?}", key.private_key);
+ let prompter = Some(prompter.as_prompter_mut())
+ .filter(|_| authenticator.prompt_ssh_key_password);
+ match key.to_credentials(username, prompter, git_config) {
+ Ok(x) => return Ok(x),
+ Err(e) => debug!("credentials_callback: failed to use SSH key from file {:?}: {e}", key.private_key),
+ }
+ }
+ }
+ }
+
+ // Sometimes libgit2 will ask for a username/password in plaintext.
+ if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
+ // Try provided plaintext credentials first.
+ if let Some(credentials) = authenticator.get_plaintext_credentials(url) {
+ debug!("credentials_callback: trying plain text credentials with username: {:?}", credentials.username);
+ match credentials.to_credentials() {
+ Ok(x) => return Ok(x),
+ Err(e) => {
+ debug!("credentials_callback: failed to wrap plain text credentials: {e}");
+ return Err(e);
+ },
+ }
+ }
+
+ // Try the git credential helper.
+ if try_cred_helper {
+ try_cred_helper = false;
+ debug!("credentials_callback: trying credential_helper");
+ match git2::Cred::credential_helper(git_config, url, username) {
+ Ok(x) => return Ok(x),
+ Err(e) => debug!("credentials_callback: failed to use credential helper: {e}"),
+ }
+ }
+
+ // Prompt the user on the terminal.
+ if try_password_prompt > 0 {
+ try_password_prompt -= 1;
+ let credentials = PlaintextCredentials::prompt(
+ prompter.as_prompter_mut(),
+ username,
+ url,
+ git_config
+ );
+ if let Some(credentials) = credentials {
+ return credentials.to_credentials();
+ }
+ }
+ }
+
+ Err(git2::Error::from_str("all authentication attempts failed"))
+ }
+}
+
+#[derive(Debug, Clone)]
+struct PrivateKeyFile {
+ private_key: PathBuf,
+ public_key: Option<PathBuf>,
+ password: Option<String>,
+}
+
+impl PrivateKeyFile {
+ fn to_credentials(&self, username: &str, prompter: Option<&mut dyn Prompter>, git_config: &git2::Config) -> Result<git2::Cred, git2::Error> {
+ if let Some(password) = &self.password {
+ git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, Some(password))
+ } else if let Some(prompter) = prompter {
+ let password = match ssh_key::analyze_ssh_key_file(&self.private_key) {
+ Err(e) => {
+ warn!("Failed to analyze SSH key: {}: {}", self.private_key.display(), e);
+ None
+ },
+ Ok(key_info) => {
+ if key_info.encrypted {
+ prompter.prompt_ssh_key_passphrase(&self.private_key, git_config)
+ } else {
+ None
+ }
+ },
+ };
+ git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, password.as_deref())
+ } else {
+ git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, None)
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct PlaintextCredentials {
+ username: String,
+ password: String,
+}
+
+impl PlaintextCredentials {
+ fn prompt(prompter: &mut dyn Prompter, username: Option<&str>, url: &str, git_config: &git2::Config) -> Option<Self> {
+ if let Some(username) = username {
+ let password = prompter.prompt_password(username, url, git_config)?;
+ Some(Self {
+ username: username.into(),
+ password,
+ })
+ } else {
+ let (username, password) = prompter.prompt_username_password(url, git_config)?;
+ Some(Self {
+ username,
+ password,
+ })
+ }
+ }
+
+ fn to_credentials(&self) -> Result<git2::Cred, git2::Error> {
+ git2::Cred::userpass_plaintext(&self.username, &self.password)
+ }
+}
+
+fn get_pub_key_path(priv_key_path: &Path) -> Option<PathBuf> {
+ let name = priv_key_path.file_name()?;
+ let name = name.to_str()?;
+ let pub_key_path = priv_key_path.with_file_name(format!("{name}.pub"));
+ if pub_key_path.is_file() {
+ Some(pub_key_path)
+ } else {
+ None
+ }
+}
+
+fn domain_from_url(url: &str) -> Option<&str> {
+ // We support:
+ // Relative paths
+ // Real URLs: scheme://[user[:pass]@]host/path
+ // SSH URLs: [user@]host:path.
+
+ // If there is no colon: URL is a relative path and there is no domain (or need for credentials).
+ let (head, tail) = url.split_once(':')?;
+
+ // Real URL
+ if let Some(tail) = tail.strip_prefix("//") {
+ let (_credentials, tail) = tail.split_once('@').unwrap_or(("", tail));
+ let (host, _path) = tail.split_once('/').unwrap_or((tail, ""));
+ Some(host)
+ // SSH "URL"
+ } else {
+ let (_credentials, host) = head.split_once('@').unwrap_or(("", head));
+ Some(host)
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use assert2::assert;
+
+ #[test]
+ fn test_domain_from_url() {
+ assert!(let Some("host") = domain_from_url("user@host:path"));
+ assert!(let Some("host") = domain_from_url("host:path"));
+ assert!(let Some("host") = domain_from_url("host:path@with:stuff"));
+
+ assert!(let Some("host") = domain_from_url("ssh://user:pass@host/path"));
+ assert!(let Some("host") = domain_from_url("ssh://user@host/path"));
+ assert!(let Some("host") = domain_from_url("ssh://host/path"));
+
+ assert!(let None = domain_from_url("some/relative/path"));
+ assert!(let None = domain_from_url("some/relative/path@with-at-sign"));
+ }
+
+ #[test]
+ fn test_that_authenticator_is_send() {
+ let authenticator = GitAuthenticator::new();
+ let thread = std::thread::spawn(move || {
+ drop(authenticator);
+ });
+ thread.join().unwrap();
+ }
+}
diff --git a/src/prompter.rs b/src/prompter.rs
new file mode 100644
index 0000000..ef3c277
--- /dev/null
+++ b/src/prompter.rs
@@ -0,0 +1,65 @@
+use std::path::Path;
+
+/// Trait for customizing user prompts.
+///
+/// You can provide an implementor of this trait to customize the way a user is prompted for credentials and passphrases.
+pub trait Prompter: Send {
+ /// Promp the user for a username and password.
+ ///
+ /// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
+ fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)>;
+
+ /// Promp the user for a password when the username is already known.
+ ///
+ /// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
+ fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String>;
+
+ /// Promp the user for the passphrase of an encrypted SSH key.
+ ///
+ /// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
+ fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String>;
+}
+
+/// Wrap a clonable [`Prompter`] in a `Box<dyn MakePrompter>`.
+pub(crate) fn wrap_prompter<P>(prompter: P) -> Box<dyn ClonePrompter>
+where
+ P: Prompter + Clone + 'static,
+{
+ Box::new(prompter)
+}
+
+/// Trait to allow making clones of a `Box<dyn Prompter + Send>`.
+pub(crate) trait ClonePrompter: Prompter {
+ /// Clone the `Box<dyn ClonePrompter>`.
+ fn dyn_clone(&self) -> Box<dyn ClonePrompter>;
+
+ /// Get `self` as plain `Prompter`.
+ fn as_prompter(&self) -> &dyn Prompter;
+
+ /// Get `self` as plain `Prompter`.
+ fn as_prompter_mut(&mut self) -> &mut dyn Prompter;
+}
+
+/// Implement `ClonePrompter` for clonable Prompters.
+impl<P> ClonePrompter for P
+where
+ P: Prompter + Clone + 'static,
+{
+ fn dyn_clone(&self) -> Box<dyn ClonePrompter> {
+ Box::new(self.clone())
+ }
+
+ fn as_prompter(&self) -> &dyn Prompter {
+ self
+ }
+
+ fn as_prompter_mut(&mut self) -> &mut dyn Prompter {
+ self
+ }
+}
+
+impl Clone for Box<dyn ClonePrompter> {
+ fn clone(&self) -> Self {
+ self.dyn_clone()
+ }
+}
diff --git a/src/ssh_key.rs b/src/ssh_key.rs
new file mode 100644
index 0000000..b3a4428
--- /dev/null
+++ b/src/ssh_key.rs
@@ -0,0 +1,156 @@
+use std::path::Path;
+
+use crate::base64_decode;
+
+/// An error that can occur when analyzing SSH keys.
+#[derive(Debug)]
+pub enum Error {
+ /// Failed to open the key file.
+ OpenFile(std::io::Error),
+
+ /// Failed to read from the key file.
+ ReadFile(std::io::Error),
+
+ /// Missing PEM trailer in the file (there was a PEM header).
+ MissingPemTrailer,
+
+ /// The key is not valid somehow.
+ MalformedKey,
+
+ /// There was an invalid base64 blob in the key.
+ Base64(base64_decode::Error),
+}
+
+/// The format of a key file.
+pub enum KeyFormat {
+ /// We don't know what format it is.
+ Unknown,
+
+ /// It's an openssh-key-v1 file.
+ ///
+ /// See https://coolaj86.com/articles/the-openssh-private-key-format/ for a description of the format.
+ OpensshKeyV1,
+}
+
+/// Information about a key file.
+pub struct KeyInfo {
+ /// The format of the key file.
+ pub format: KeyFormat,
+
+ /// Is the key encrypted?
+ pub encrypted: bool,
+}
+
+/// Analyze an SSH key file.
+pub fn analyze_ssh_key_file(priv_key_path: &Path) -> Result<KeyInfo, Error> {
+ use std::io::Read;
+
+ let mut buffer = Vec::new();
+ let mut file = std::fs::File::open(priv_key_path)
+ .map_err(Error::OpenFile)?;
+ file.read_to_end(&mut buffer)
+ .map_err(Error::ReadFile)?;
+ analyze_pem_openssh_key(&buffer)
+}
+
+/// Analyze a PEM encoded openssh-key-v1 file.
+fn analyze_pem_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
+ let data = trim_bytes(data);
+ let data = match data.strip_prefix(b"-----BEGIN OPENSSH PRIVATE KEY-----") {
+ Some(x) => x,
+ None => return Ok(KeyInfo { format: KeyFormat::Unknown, encrypted: false }),
+ };
+ let data = match data.strip_suffix(b"-----END OPENSSH PRIVATE KEY-----") {
+ Some(x) => x,
+ None => return Err(Error::MissingPemTrailer),
+ };
+ let data = base64_decode::base64_decode(data).map_err(Error::Base64)?;
+ analyze_binary_openssh_key(&data)
+}
+
+/// Analyze a binary openss-key-v1 blob.
+fn analyze_binary_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
+ let tail = data.strip_prefix(b"openssh-key-v1\0")
+ .ok_or(Error::MalformedKey)?;
+ if tail.len() <= 4 {
+ return Err(Error::MalformedKey);
+ }
+
+ let (cipher_len, tail) = tail.split_at(4);
+ let cipher_len = u32::from_be_bytes(cipher_len.try_into().unwrap()) as usize;
+ if tail.len() < cipher_len {
+ return Err(Error::MalformedKey);
+ }
+ let cipher = &tail[..cipher_len];
+ let encrypted = cipher != b"none";
+ Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted })
+}
+
+/// Trim whitespace from the start and end of a byte slice.
+fn trim_bytes(data: &[u8]) -> &[u8] {
+ let data = match data.iter().position(|b| !b.is_ascii_whitespace()) {
+ Some(x) => &data[x..],
+ None => return b"",
+ };
+ let data = match data.iter().rposition(|b| !b.is_ascii_whitespace()) {
+ Some(x) => &data[..=x],
+ None => return b"",
+ };
+ data
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::OpenFile(e) => write!(f, "Failed to open file: {e}"),
+ Self::ReadFile(e) => write!(f, "Failed to read from file: {e}"),
+ Self::MissingPemTrailer => write!(f, "Missing PEM trailer in key file"),
+ Self::MalformedKey => write!(f, "Invalid or malformed key file"),
+ Self::Base64(e) => write!(f, "Invalid base64 in key file: {e}"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use assert2::assert;
+
+ #[test]
+ fn test_is_encrypted_pem_openssh_key() {
+ // Encrypted OpenSSH key.
+ assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
+ "-----BEGIN OPENSSH PRIVATE KEY-----\n",
+ "b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
+ "6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n",
+ "8eQFG1zKYlhtLLz2GC3Sou+C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
+ "rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
+ "JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
+ "XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
+ "-----END OPENSSH PRIVATE KEY-----\n",
+ ).as_bytes()));
+
+ // Encrypted OpenSSH key with extra random whitespace.
+ assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
+ " \n\t\r-----BEGIN OPENSSH PRIVATE KEY-----\n",
+ "b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
+ "6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n \r",
+ "8eQFG1zKYlhtLLz2GC3Sou+ C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
+ "rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
+ "JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
+ "XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
+ "-----END OPENSSH PRIVATE KEY-----",
+ ).as_bytes()));
+
+ // Unencrypted OpenSSH key.
+ assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: false }) = analyze_pem_openssh_key(concat!(
+ "-----BEGIN OPENSSH PRIVATE KEY-----\n",
+ "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
+ "QyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3AAAAJhSNRa9UjUW\n",
+ "vQAAAAtzc2gtZWQyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3A\n",
+ "AAAECZObXz1xTSvl4vpLsMVTuhjroyDteKlW+Uun0yIMl7edMozT5FjMQugt7C/mflSgQ+\n",
+ "GYKnCSu1czgalZRUX7DcAAAAEW1hYXJ0ZW5AbWFnbmV0cm9uAQIDBA==\n",
+ "-----END OPENSSH PRIVATE KEY-----\n",
+ ).as_bytes()));
+ }
+}