diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-20 07:48:15 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-20 07:48:15 +0100 |
commit | 08ee41641952141f57e87048dc4867a800f4587a (patch) | |
tree | 89107d02a7a5e84ba38b4a6458daf09074b990b6 | |
parent | Initial commit. (diff) | |
download | rust-auth-git2-upstream.tar.xz rust-auth-git2-upstream.zip |
Adding upstream version 0.5.5.upstream/0.5.5upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
-rw-r--r-- | .cargo_vcs_info.json | 6 | ||||
-rw-r--r-- | .github/workflows/rust.yml | 22 | ||||
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | CHANGELOG | 53 | ||||
-rw-r--r-- | Cargo.lock | 785 | ||||
-rw-r--r-- | Cargo.toml | 60 | ||||
-rw-r--r-- | Cargo.toml.orig | 29 | ||||
-rw-r--r-- | LICENSE.md | 24 | ||||
-rw-r--r-- | README.md | 100 | ||||
-rw-r--r-- | README.tpl | 16 | ||||
-rw-r--r-- | examples/custom-prompt-clone.rs | 139 | ||||
-rw-r--r-- | examples/git.rs | 146 | ||||
-rw-r--r-- | src/base64_decode.rs | 110 | ||||
-rw-r--r-- | src/default_prompt.rs | 183 | ||||
-rw-r--r-- | src/lib.rs | 719 | ||||
-rw-r--r-- | src/prompter.rs | 65 | ||||
-rw-r--r-- | src/ssh_key.rs | 156 |
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())); + } +} |