diff --git a/Cargo.lock b/Cargo.lock index eed3b64fbae..192999c021b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -489,6 +498,15 @@ dependencies = [ "cache-padded", ] +[[package]] +name = "conpty" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977baae4026273d7f9bb69a0a8eb4aed7ab9dac98799f742dce09173a9734754" +dependencies = [ + "windows 0.29.0", +] + [[package]] name = "console" version = "0.15.1" @@ -882,6 +900,18 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "expectrl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2795e11f4ee3124984d454f25ac899515a5fa6d956562ef2b147fef6050b02f8" +dependencies = [ + "conpty", + "nix 0.23.1", + "ptyprocess", + "regex", +] + [[package]] name = "fastrand" version = "1.8.0" @@ -1107,6 +1137,13 @@ dependencies = [ "quick-error", ] +[[package]] +name = "git-command" +version = "0.1.0" +dependencies = [ + "git-testtools", +] + [[package]] name = "git-commitgraph" version = "0.8.2" @@ -1126,10 +1163,10 @@ dependencies = [ name = "git-config" version = "0.7.1" dependencies = [ - "bitflags", "bstr", "criterion", "document-features", + "git-config-value", "git-features", "git-glob", "git-path", @@ -1137,7 +1174,6 @@ dependencies = [ "git-repository", "git-sec", "git-testtools", - "libc", "memchr", "nom", "serde", @@ -1149,6 +1185,19 @@ dependencies = [ "unicode-bom", ] +[[package]] +name = "git-config-value" +version = "0.7.0" +dependencies = [ + "bitflags", + "bstr", + "document-features", + "git-path", + "libc", + "serde", + "thiserror", +] + [[package]] name = "git-conventional" version = "0.11.3" @@ -1166,9 +1215,15 @@ version = "0.4.0" dependencies = [ "bstr", "document-features", + "git-command", + "git-config-value", + "git-path", + "git-prompt", "git-sec", - "quick-error", + "git-testtools", + "git-url", "serde", + "thiserror", ] [[package]] @@ -1408,7 +1463,7 @@ dependencies = [ [[package]] name = "git-path" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bstr", "tempfile", @@ -1428,6 +1483,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "git-prompt" +version = "0.1.0" +dependencies = [ + "expectrl", + "git-command", + "git-config-value", + "git-testtools", + "nix 0.25.0", + "parking_lot 0.12.1", + "serial_test 0.9.0", + "thiserror", +] + [[package]] name = "git-protocol" version = "0.20.0" @@ -1526,6 +1595,7 @@ dependencies = [ "git-odb", "git-pack", "git-path", + "git-prompt", "git-protocol", "git-ref", "git-refspec", @@ -1540,6 +1610,7 @@ dependencies = [ "git-worktree", "is_ci", "log", + "once_cell", "regex", "serde", "serial_test 0.8.0", @@ -1579,7 +1650,7 @@ dependencies = [ "serde", "tempfile", "thiserror", - "windows", + "windows 0.37.0", ] [[package]] @@ -1771,11 +1842,11 @@ dependencies = [ "git-url", "itertools", "jwalk", - "quick-error", "rayon", "serde", "serde_json", "tempfile", + "thiserror", ] [[package]] @@ -2144,6 +2215,44 @@ dependencies = [ "winapi", ] +[[package]] +name = "nix" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3728fec49d363a50a8828a190b379a446cc5cf085c06259bbbeb34447e4ec7" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f866317acbd3a240710c63f065ffb1e4fd466259045ccb504130b7f668f35c6" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e322c04a9e3440c327fca7b6c8a63e6890a32fa2ad689db972425f07e0d22abb" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "nom" version = "7.1.1" @@ -2204,9 +2313,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" +checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" [[package]] name = "oorandom" @@ -2447,6 +2556,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ptyprocess" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69c28fcebfd842bfe19d69409fc321230ea8c1bebe31f274906485c761ce1917" +dependencies = [ + "nix 0.21.0", +] + [[package]] name = "pulldown-cmark" version = "0.9.2" @@ -2524,6 +2642,8 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] @@ -2666,6 +2786,20 @@ dependencies = [ "serial_test_derive 0.8.0", ] +[[package]] +name = "serial_test" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92761393ee4dc3ff8f4af487bd58f4307c9329bbedea02cac0089ad9c411e153" +dependencies = [ + "dashmap", + "futures", + "lazy_static", + "log", + "parking_lot 0.12.1", + "serial_test_derive 0.9.0", +] + [[package]] name = "serial_test_derive" version = "0.7.0" @@ -2692,6 +2826,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serial_test_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6f5d1c3087fb119617cff2966fe3808a80e5eb59a8c1601d5994d66f4346a5" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.2" @@ -2884,18 +3030,18 @@ checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" [[package]] name = "thiserror" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0a539a918745651435ac7db7a18761589a94cd7e94cd56999f828bf73c8a57" +checksum = "8c1b05ca9d106ba7d2e31a9dab4a64e7be2cce415321966ea3132c49a656e252" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c251e90f708e16c49a16f4917dc2131e75222b72edfa9cb7f7c58ae56aae0c09" +checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" dependencies = [ "proc-macro2", "quote", @@ -3243,6 +3389,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac7fef12f4b59cd0a29339406cc9203ab44e440ddff6b3f5a41455349fa9cf3" +dependencies = [ + "windows_aarch64_msvc 0.29.0", + "windows_i686_gnu 0.29.0", + "windows_i686_msvc 0.29.0", + "windows_x86_64_gnu 0.29.0", + "windows_x86_64_msvc 0.29.0", +] + [[package]] name = "windows" version = "0.37.0" @@ -3269,6 +3428,12 @@ dependencies = [ "windows_x86_64_msvc 0.36.1", ] +[[package]] +name = "windows_aarch64_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d027175d00b01e0cbeb97d6ab6ebe03b12330a35786cbaca5252b1c4bf5d9b" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" @@ -3281,6 +3446,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" +[[package]] +name = "windows_i686_gnu" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8793f59f7b8e8b01eda1a652b2697d87b93097198ae85f823b969ca5b89bba58" + [[package]] name = "windows_i686_gnu" version = "0.36.1" @@ -3293,6 +3464,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" +[[package]] +name = "windows_i686_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8602f6c418b67024be2996c512f5f995de3ba417f4c75af68401ab8756796ae4" + [[package]] name = "windows_i686_msvc" version = "0.36.1" @@ -3305,6 +3482,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" +[[package]] +name = "windows_x86_64_gnu" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d615f419543e0bd7d2b3323af0d86ff19cbc4f816e6453f36a2c2ce889c354" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" @@ -3317,6 +3500,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d95421d9ed3672c280884da53201a5c46b7b2765ca6faf34b0d71cf34a3561" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" diff --git a/Cargo.toml b/Cargo.toml index 080cd22d06c..d46150c4716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,7 +126,9 @@ members = [ "git-hash", "git-validate", "git-ref", + "git-command", "git-config", + "git-config-value", "git-discover", "git-features", "git-commitgraph", @@ -144,6 +146,7 @@ members = [ "git-packetline", "git-mailmap", "git-note", + "git-prompt", "git-filter", "git-sec", "git-lfs", diff --git a/Makefile b/Makefile index 662f79c5e99..e81fdf3dbf3 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ clippy: ## Run cargo clippy on all crates cargo clippy --all --no-default-features --features lean-async --tests check-msrv: ## run cargo msrv to validate the current msrv requirements, similar to what CI does - cd git-repository && cargo check --package git-repository --no-default-features --features async-network-client,unstable,max-performance + cd git-repository && cargo check --package git-repository --no-default-features --features async-network-client,max-performance check: ## Build all code in suitable configurations cargo check --all @@ -107,6 +107,8 @@ check: ## Build all code in suitable configurations && cargo check --features cache-efficiency-debug cd git-commitgraph && cargo check --all-features \ && cargo check + cd git-config-value && cargo check --all-features \ + && cargo check cd git-config && cargo check --all-features \ && cargo check cd git-transport && cargo check \ @@ -280,7 +282,7 @@ bench-git-config: check-msrv-on-ci: ## Check the minimal support rust version for currently installed Rust version rustc --version cargo check --package git-repository - cargo check --package git-repository --no-default-features --features async-network-client,unstable,max-performance + cargo check --package git-repository --no-default-features --features async-network-client,max-performance ##@ Maintenance diff --git a/README.md b/README.md index 4df931cd798..f18bf132b96 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Please see _'Development Status'_ for a listing of all crates and their capabili * [x] **explain** - show what would be done while parsing a revision specification like `HEAD~1` * [x] **resolve** - show which objects a revspec resolves to, similar to `git rev-parse` but faster and with much better error handling * [x] **previous-branches** - list all previously checked out branches, powered by the ref-log. + * **remote** + * [x] **refs** - list all references available on the remote based on the current remote configuration. + * **credential** + * [x] **fill/approve/reject** - The same as `git credential`, but implemented in Rust, calling helpers only when from trusted configuration. * **free** - no git repository necessary * **pack** * [x] [verify](https://asciinema.org/a/352942) @@ -75,8 +79,6 @@ Please see _'Development Status'_ for a listing of all crates and their capabili * [x] detailed information about the TREE extension * [ ] …other extensions details aren't implemented yet * [x] **checkout-exclusive** - a predecessor of `git worktree`, providing flexible options to evaluate checkout performance from an index and/or an object database. - * **remote** - * [ref-list](https://asciinema.org/a/359320) - list all (or given) references from a remote at the given URL [skim]: https://github.com/lotabout/skim [git-hours]: https://github.com/kimmobrunfeldt/git-hours/blob/8aaeee237cb9d9028e7a2592a25ad8468b1f45e4/index.js#L114-L143 @@ -105,6 +107,7 @@ Documentation is complete and was reviewed at least once. * [git-chunk](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-chunk) * [git-ref](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-ref) * [git-config](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-config) +* [git-config-value](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-config-value) * [git-glob](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-glob) ### Initial Development @@ -137,6 +140,8 @@ is usable to some extend. * [git-pathspec](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-pathspec) * [git-index](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-index) * [git-revision](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-revision) + * [git-command](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-command) + * [git-prompt](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-prompt) * `gitoxide-core` * **very early** _(possibly without any documentation and many rough edges)_ * [git-worktree](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-worktree) diff --git a/cargo-smart-release/Cargo.toml b/cargo-smart-release/Cargo.toml index 36f668b53b7..393cd45ec13 100644 --- a/cargo-smart-release/Cargo.toml +++ b/cargo-smart-release/Cargo.toml @@ -24,7 +24,7 @@ test = false cache-efficiency-debug = ["git-repository/cache-efficiency-debug"] [dependencies] -git-repository = { version = "^0.24.0", path = "../git-repository", default-features = false, features = ["unstable", "max-performance-safe"] } +git-repository = { version = "^0.24.0", path = "../git-repository", default-features = false, features = ["max-performance-safe"] } anyhow = "1.0.42" clap = { version = "3.2.5", features = ["derive", "cargo"] } env_logger = { version = "0.9.0", default-features = false, features = ["humantime", "termcolor", "atty"] } diff --git a/crate-status.md b/crate-status.md index 7e5fb15b135..f9a38016f00 100644 --- a/crate-status.md +++ b/crate-status.md @@ -143,7 +143,9 @@ Check out the [performance discussion][git-traverse-performance] as well. * [x] convert URL to string * [x] API documentation * [ ] Some examples - +- **deviation** + * URLs may not contain passwords, which cannot be represent here and if present, will be ignored. + ### git-protocol * _abstract over protocol versions to allow delegates to deal only with a single way of doing things_ * [x] **credentials** @@ -240,6 +242,18 @@ Check out the [performance discussion][git-traverse-performance] as well. * [ ] for fetch * [ ] for push +### git-command +* [x] execute commands directly +* [x] execute commands with `sh` +* [ ] support for `GIT_EXEC_PATH` environment variable with `git-sec` filter + +### git-prompt +* [x] open prompts for usernames for example +* [x] secure prompts for password +* [x] use `askpass` program if available +* [ ] signal handling (resetting and restoring terminal settings) +* [ ] windows prompts for `cmd.exe` and mingw terminals + ### git-note A mechanism to associate metadata with any object, and keep revisions of it using git itself. @@ -262,6 +276,11 @@ A mechanism to associate metadata with any object, and keep revisions of it usin ### git-credentials * [x] launch git credentials helpers with a given action + - [x] built-in `git credential` program + - [x] as scripts + - [x] as absolute paths to programs with optional arguments + - [x] program name with optional arguments, transformed into `git credential-` +* [x] `helper::main()` for easy custom credential helper programs written in Rust ### git-filter @@ -393,18 +412,20 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-tempfile/REA See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README.md). +### git-config-value +* **parse** + * [x] boolean + * [x] integer + * [x] color + * [ ] ANSI code output for terminal colors + * [x] path (incl. resolution) + * [ ] date + * [ ] [permission][https://github.com/git/git/blob/71a8fab31b70c417e8f5b5f716581f89955a7082/setup.c#L1526:L1526] + ### git-config * [x] read * zero-copy parsing with event emission - * [x] decode value - * [x] boolean - * [x] integer - * [x] color - * [ ] ANSI code output for terminal colors - * [x] path (incl. resolution) - * [ ] date - * [ ] [permission][https://github.com/git/git/blob/71a8fab31b70c417e8f5b5f716581f89955a7082/setup.c#L1526:L1526] - * [x] include + * all config values as per the `git-config-value` crate * **includeIf** * [x] `gitdir`, `gitdir/i`, and `onbranch` * [ ] `hasconfig` @@ -415,8 +436,8 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README. * keep comments and whitespace, and only change lines that are affected by actual changes, to allow truly non-destructive editing * [x] cascaded loading of various configuration files into one * [x] load from environment variables - * [ ] load from well-known sources for global configuration - * [ ] load repository configuration with all known sources + * [x] load from well-known sources for global configuration + * [x] load repository configuration with all known sources * [x] API documentation * [x] Some examples @@ -428,16 +449,20 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README. * [x] discovery * [x] option to not cross file systems (default) * [x] handle git-common-dir - * [ ] support for `GIT_CEILING_DIRECTORIES` environment variable + * [x] support for `GIT_CEILING_DIRECTORIES` environment variable * [ ] handle other non-discovery modes and provide control over environment variable usage required in applications * [x] rev-parse - - **deviation** - * `@` actually stands for `HEAD`, whereas `git` resolves it to the object pointed to by `HEAD` without making the `HEAD` ref available for lookups. * [x] rev-walk * [x] include tips * [ ] exclude commits * [x] instantiation * [x] access to refs and objects + * **credentials** + * [x] run `git credential` directly + * [x] use credential helper configuration and to obtain credentials with `git_credential::helper::Cascade` + * **config** + * [ ] facilities to apply the [url-match](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httplturlgt) algorithm and to + [normalize urls](https://github.com/git/git/blob/be1a02a17ede4082a86dfbfee0f54f345e8b43ac/urlmatch.c#L109:L109) before comparison. * **traverse** * [x] commit graphs * [ ] make [git-notes](https://git-scm.com/docs/git-notes) accessible @@ -469,8 +494,10 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README. * [ ] shallow * [ ] fetch * [ ] push - * [ ] ls-refs - * [ ] list, find by name, create in memory. + * [x] ls-refs + * [ ] ls-refs with ref-spec filter + * [ ] list, find by name + * [x] create in memory * [ ] groups * [ ] [remote and branch files](https://github.com/git/git/blob/master/remote.c#L300) * [ ] execute hooks diff --git a/deny.toml b/deny.toml index 106b30ee1b0..c6ddf8ae758 100644 --- a/deny.toml +++ b/deny.toml @@ -22,7 +22,7 @@ yanked = "warn" # 2019-12-17 there are no security notice advisories in # https://github.com/rustsec/advisory-db notice = "warn" -ignore = [ ] +ignore = ["RUSTSEC-2021-0119"] # 'nix' comes in via `expectrl`, which is a dev-dependency only. Somewhat unmaintained. diff --git a/etc/check-package-size.sh b/etc/check-package-size.sh index 3ac25273591..52501746588 100755 --- a/etc/check-package-size.sh +++ b/etc/check-package-size.sh @@ -29,7 +29,9 @@ echo "in root: gitoxide CLI" (enter git-bitmap && indent cargo diet -n --package-size-limit 5KB) (enter git-tempfile && indent cargo diet -n --package-size-limit 30KB) (enter git-lock && indent cargo diet -n --package-size-limit 20KB) -(enter git-config && indent cargo diet -n --package-size-limit 115KB) +(enter git-config && indent cargo diet -n --package-size-limit 120KB) +(enter git-config-value && indent cargo diet -n --package-size-limit 20KB) +(enter git-command && indent cargo diet -n --package-size-limit 5KB) (enter git-hash && indent cargo diet -n --package-size-limit 20KB) (enter git-chunk && indent cargo diet -n --package-size-limit 10KB) (enter git-rebase && indent cargo diet -n --package-size-limit 5KB) @@ -46,13 +48,14 @@ echo "in root: gitoxide CLI" (enter git-note && indent cargo diet -n --package-size-limit 5KB) (enter git-sec && indent cargo diet -n --package-size-limit 15KB) (enter git-tix && indent cargo diet -n --package-size-limit 5KB) -(enter git-credentials && indent cargo diet -n --package-size-limit 10KB) +(enter git-credentials && indent cargo diet -n --package-size-limit 20KB) +(enter git-prompt && indent cargo diet -n --package-size-limit 15KB) (enter git-object && indent cargo diet -n --package-size-limit 25KB) (enter git-commitgraph && indent cargo diet -n --package-size-limit 25KB) (enter git-pack && indent cargo diet -n --package-size-limit 115KB) (enter git-odb && indent cargo diet -n --package-size-limit 120KB) (enter git-protocol && indent cargo diet -n --package-size-limit 50KB) (enter git-packetline && indent cargo diet -n --package-size-limit 35KB) -(enter git-repository && indent cargo diet -n --package-size-limit 160KB) +(enter git-repository && indent cargo diet -n --package-size-limit 175KB) (enter git-transport && indent cargo diet -n --package-size-limit 55KB) (enter gitoxide-core && indent cargo diet -n --package-size-limit 80KB) diff --git a/experiments/diffing/Cargo.toml b/experiments/diffing/Cargo.toml index 49e3a5845ec..0cc07fde6cf 100644 --- a/experiments/diffing/Cargo.toml +++ b/experiments/diffing/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] anyhow = "1" -git-repository = { version = "^0.24.0", path = "../../git-repository", features = ["unstable"] } +git-repository = { version = "^0.24.0", path = "../../git-repository" } git-features-for-config = { package = "git-features", version = "^0.22.4", path = "../../git-features", features = ["cache-efficiency-debug"] } git2 = "0.14" rayon = "1.5.0" diff --git a/experiments/object-access/Cargo.toml b/experiments/object-access/Cargo.toml index 225923a8c1d..c055b64a38e 100644 --- a/experiments/object-access/Cargo.toml +++ b/experiments/object-access/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] anyhow = "1" -git-repository = { path = "../../git-repository", version = "^0.24.0", features = ["unstable"] } +git-repository = { path = "../../git-repository", version = "^0.24.0" } git2 = "0.14" rayon = "1.5.0" parking_lot = { version = "0.12.0", default-features = false } diff --git a/experiments/traversal/Cargo.toml b/experiments/traversal/Cargo.toml index c1a82d39f19..d96766c9a6e 100644 --- a/experiments/traversal/Cargo.toml +++ b/experiments/traversal/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] anyhow = "1" -git-repository = { version = "^0.24.0", path = "../../git-repository", features = ["unstable"] } +git-repository = { version = "^0.24.0", path = "../../git-repository" } git2 = "0.14" rayon = "1.5.0" dashmap = "5.1.0" diff --git a/git-attributes/Cargo.toml b/git-attributes/Cargo.toml index fa3a11f3682..fc5ce6bdb19 100644 --- a/git-attributes/Cargo.toml +++ b/git-attributes/Cargo.toml @@ -17,7 +17,7 @@ serde1 = ["serde", "bstr/serde1", "git-glob/serde1", "compact_str/serde"] [dependencies] git-features = { version = "^0.22.4", path = "../git-features" } -git-path = { version = "^0.4.1", path = "../git-path" } +git-path = { version = "^0.4.2", path = "../git-path" } git-quote = { version = "^0.2.1", path = "../git-quote" } git-glob = { version = "^0.3.2", path = "../git-glob" } diff --git a/git-command/CHANGELOG.md b/git-command/CHANGELOG.md new file mode 100644 index 00000000000..bebed83198f --- /dev/null +++ b/git-command/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.0.0 (2022-08-25) + +Initial release to reserve the name. + +### Commit Statistics + + + + - 2 commits contributed to the release. + - 0 commits where understood as [conventional](https://www.conventionalcommits.org). + - 1 unique issue was worked on: [#450](https://github.com/Byron/gitoxide/issues/450) + +### Commit Details + + + +
view details + + * **[#450](https://github.com/Byron/gitoxide/issues/450)** + - prepare changelog prior to release ([`579e8f1`](https://github.com/Byron/gitoxide/commit/579e8f138963a057d87837301b097fd804424447)) + - first frame of `git-command` crate ([`436632a`](https://github.com/Byron/gitoxide/commit/436632a3822d3671c073cdbbbaf8e569de62bb09)) +
+ diff --git a/git-command/Cargo.toml b/git-command/Cargo.toml new file mode 100644 index 00000000000..53764a4f9c3 --- /dev/null +++ b/git-command/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "git-command" +version = "0.1.0" +repository = "https://github.com/Byron/gitoxide" +license = "MIT/Apache-2.0" +description = "A WIP crate of the gitoxide project handling internal git command execution" +authors = ["Sebastian Thiel "] +edition = "2018" + +[lib] +doctest = false + +[dependencies] +git-testtools = { path = "../tests/tools" } diff --git a/git-command/src/lib.rs b/git-command/src/lib.rs new file mode 100644 index 00000000000..8ac48bfd082 --- /dev/null +++ b/git-command/src/lib.rs @@ -0,0 +1,106 @@ +//! Launch commands very similarly to `Command`, but with `git` specific capabilities and adjustments. +#![deny(rust_2018_idioms, missing_docs)] +#![forbid(unsafe_code)] + +use std::ffi::OsString; + +/// A structure to keep settings to use when invoking a command via [`spawn()`][Prepare::spawn()], after creating it with [`prepare()`]. +pub struct Prepare { + command: OsString, + stdin: std::process::Stdio, + stdout: std::process::Stdio, + stderr: std::process::Stdio, + args: Vec, + use_shell: bool, +} + +mod prepare { + use crate::Prepare; + use git_testtools::bstr::ByteSlice; + use std::process::{Command, Stdio}; + + /// Builder + impl Prepare { + /// If called, the command will not be executed directly, but with `sh`. + /// + /// This also allows to pass shell scripts as command, or use commands that contain arguments which are subsequently + /// parsed by `sh`. + pub fn with_shell(mut self) -> Self { + self.use_shell = self.command.to_str().map_or(true, |cmd| { + cmd.as_bytes().find_byteset(b"|&;<>()$`\\\"' \t\n*?[#~=%").is_some() + }); + self + } + + /// Configure the process to use `stdio` for _stdin. + pub fn stdin(mut self, stdio: Stdio) -> Self { + self.stdin = stdio; + self + } + /// Configure the process to use `stdio` for _stdout_. + pub fn stdout(mut self, stdio: Stdio) -> Self { + self.stdout = stdio; + self + } + /// Configure the process to use `stdio` for _stderr. + pub fn stderr(mut self, stdio: Stdio) -> Self { + self.stderr = stdio; + self + } + + /// Add `arg` to the list of arguments to call the command with. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + } + + /// Finalization + impl Prepare { + /// Spawn the command as configured. + pub fn spawn(self) -> std::io::Result { + let mut cmd: Command = self.into(); + cmd.spawn() + } + } + + impl From for Command { + fn from(mut prep: Prepare) -> Command { + let mut cmd = if prep.use_shell { + let mut cmd = Command::new(if cfg!(windows) { "sh" } else { "/bin/sh" }); + cmd.arg("-c"); + if !prep.args.is_empty() { + prep.command.push(" \"$@\"") + } + cmd.arg(prep.command); + cmd.arg("--"); + cmd + } else { + Command::new(prep.command) + }; + cmd.stdin(prep.stdin) + .stdout(prep.stdout) + .stderr(prep.stderr) + .args(prep.args); + cmd + } + } +} + +/// Prepare `cmd` for [spawning][std::process::Command::spawn()] by configuring it with various builder methods. +/// +/// Note that the default IO is configured for typical API usage, that is +/// +/// - `stdin` is null to prevent blocking unexpectedly on consumption of stdin +/// - `stdout` is captured for consumption by the caller +/// - `stderr` is inherited to allow the command to provide context to the user +pub fn prepare(cmd: impl Into) -> Prepare { + Prepare { + command: cmd.into(), + stdin: std::process::Stdio::null(), + stdout: std::process::Stdio::piped(), + stderr: std::process::Stdio::inherit(), + args: Vec::new(), + use_shell: false, + } +} diff --git a/git-command/tests/command.rs b/git-command/tests/command.rs new file mode 100644 index 00000000000..f4f273329fc --- /dev/null +++ b/git-command/tests/command.rs @@ -0,0 +1,70 @@ +use git_testtools::Result; + +mod spawn { + #[test] + fn direct_command_execution_searches_in_path() -> crate::Result { + assert!(git_command::prepare(if cfg!(unix) { "ls" } else { "dir.exe" }) + .spawn()? + .wait()? + .success()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn direct_command_with_absolute_command_path() -> crate::Result { + assert!(git_command::prepare("/bin/ls").spawn()?.wait()?.success()); + Ok(()) + } + + mod with_shell { + use git_testtools::bstr::ByteSlice; + + #[test] + fn command_in_path_with_args() -> crate::Result { + assert!(git_command::prepare(if cfg!(unix) { "ls -l" } else { "dir.exe -a" }) + .with_shell() + .spawn()? + .wait()? + .success()); + Ok(()) + } + + #[test] + fn sh_shell_specific_script_code() -> crate::Result { + assert!(git_command::prepare(":;:;:").with_shell().spawn()?.wait()?.success()); + Ok(()) + } + + #[test] + fn sh_shell_specific_script_code_with_single_extra_arg() -> crate::Result { + let out = git_command::prepare("echo") + .with_shell() + .arg("1") + .spawn()? + .wait_with_output()?; + assert!(out.status.success()); + #[cfg(not(windows))] + assert_eq!(out.stdout.as_bstr(), "1\n"); + #[cfg(windows)] + assert_eq!(out.stdout.as_bstr(), "1\r\n"); + Ok(()) + } + + #[test] + fn sh_shell_specific_script_code_with_multiple_extra_args() -> crate::Result { + let out = git_command::prepare("echo") + .with_shell() + .arg("1") + .arg("2") + .spawn()? + .wait_with_output()?; + assert!(out.status.success()); + #[cfg(not(windows))] + assert_eq!(out.stdout.as_bstr(), "1 2\n"); + #[cfg(windows)] + assert_eq!(out.stdout.as_bstr(), "1 2\r\n"); + Ok(()) + } + } +} diff --git a/git-config-value/CHANGELOG.md b/git-config-value/CHANGELOG.md new file mode 100644 index 00000000000..f075712780c --- /dev/null +++ b/git-config-value/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## v0.7.0 (2022-08-29) + +### Changed + + - `git-config` now uses `git-config-value`. + +### Commit Statistics + + + + - 6 commits contributed to the release. + - 1 commit where understood as [conventional](https://www.conventionalcommits.org). + - 1 unique issue was worked on: [#450](https://github.com/Byron/gitoxide/issues/450) + +### Commit Details + + + +
view details + + * **[#450](https://github.com/Byron/gitoxide/issues/450)** + - add changelog ([`c396ba1`](https://github.com/Byron/gitoxide/commit/c396ba17f3f674c3af7460534860fc0dc462d401)) + - `git-config` now uses `git-config-value`. ([`5ad2965`](https://github.com/Byron/gitoxide/commit/5ad296577d837b0699b4718fa2be3d0978c4e342)) + - port tests over as well ([`9b28df2`](https://github.com/Byron/gitoxide/commit/9b28df22b858b6f1c9ca9b07a5a1c0cc300b50f0)) + - copy all value code from git-config to the dedicated crate ([`edb1162`](https://github.com/Byron/gitoxide/commit/edb1162e284e343e2c575980854b8292de9c968f)) + - add new git-config-value crate ([`f87edf2`](https://github.com/Byron/gitoxide/commit/f87edf26c1cb795142fbe95e12c0dfc1166e4233)) + * **Uncategorized** + - Release git-path v0.4.2, git-config-value v0.7.0 ([`c48fb31`](https://github.com/Byron/gitoxide/commit/c48fb3107d29f9a06868b0c6de40567063a656d1)) +
+ diff --git a/git-config-value/Cargo.toml b/git-config-value/Cargo.toml new file mode 100644 index 00000000000..8b5ab3050db --- /dev/null +++ b/git-config-value/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "git-config-value" +version = "0.7.0" +repository = "https://github.com/Byron/gitoxide" +license = "MIT/Apache-2.0" +description = "A crate of the gitoxide project providing git-config value parsing" +authors = ["Sebastian Thiel "] +edition = "2018" + +[lib] +doctest = false + +[features] +## Data structures implement `serde::Serialize` and `serde::Deserialize`. +serde1 = ["serde", "bstr/serde1"] + +[dependencies] +git-path = { version = "^0.4.2", path = "../git-path" } + +thiserror = "1.0.32" +bstr = "0.2.17" +serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} +bitflags = "1.3.2" + +document-features = { version = "0.2.0", optional = true } + +[target.'cfg(not(windows))'.dependencies] +libc = "0.2" + +[package.metadata.docs.rs] +all-features = true +features = ["document-features"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/git-config/src/values/boolean.rs b/git-config-value/src/boolean.rs similarity index 79% rename from git-config/src/values/boolean.rs rename to git-config-value/src/boolean.rs index ed10fb2210c..43019e156e3 100644 --- a/git-config/src/values/boolean.rs +++ b/git-config-value/src/boolean.rs @@ -1,18 +1,29 @@ +use std::ffi::OsString; use std::{borrow::Cow, convert::TryFrom, fmt::Display}; use bstr::{BStr, BString, ByteSlice}; -use crate::{value, Boolean}; +use crate::{Boolean, Error}; -fn bool_err(input: impl Into) -> value::Error { - value::Error::new( +fn bool_err(input: impl Into) -> Error { + Error::new( "Booleans need to be 'no', 'off', 'false', '' or 'yes', 'on', 'true' or any number", input, ) } +impl TryFrom for Boolean { + type Error = Error; + + fn try_from(value: OsString) -> Result { + let value = git_path::os_str_into_bstr(&value) + .map_err(|_| Error::new("Illformed UTF-8", std::path::Path::new(&value).display().to_string()))?; + Self::try_from(value) + } +} + impl TryFrom<&BStr> for Boolean { - type Error = value::Error; + type Error = Error; fn try_from(value: &BStr) -> Result { if parse_true(value) { @@ -40,7 +51,7 @@ impl Boolean { } impl TryFrom> for Boolean { - type Error = value::Error; + type Error = Error; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) } diff --git a/git-config/src/values/color.rs b/git-config-value/src/color.rs similarity index 97% rename from git-config/src/values/color.rs rename to git-config-value/src/color.rs index 49a63f11c5b..aa0e9384fc2 100644 --- a/git-config/src/values/color.rs +++ b/git-config-value/src/color.rs @@ -3,7 +3,7 @@ use std::{borrow::Cow, convert::TryFrom, fmt::Display, str::FromStr}; use bstr::{BStr, BString}; -use crate::{value, Color}; +use crate::{Color, Error}; impl Display for Color { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -31,15 +31,15 @@ impl Display for Color { } } -fn color_err(input: impl Into) -> value::Error { - value::Error::new( +fn color_err(input: impl Into) -> Error { + Error::new( "Colors are specific color values and their attributes, like 'brightred', or 'blue'", input, ) } impl TryFrom<&BStr> for Color { - type Error = value::Error; + type Error = Error; fn try_from(s: &BStr) -> Result { let s = std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?; @@ -90,7 +90,7 @@ impl TryFrom<&BStr> for Color { } impl TryFrom> for Color { - type Error = value::Error; + type Error = Error; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) @@ -164,7 +164,7 @@ impl serde::Serialize for Name { } impl FromStr for Name { - type Err = value::Error; + type Err = Error; fn from_str(mut s: &str) -> Result { let bright = if let Some(rest) = s.strip_prefix("bright") { @@ -222,7 +222,7 @@ impl FromStr for Name { } impl TryFrom<&BStr> for Name { - type Error = value::Error; + type Error = Error; fn try_from(s: &BStr) -> Result { Self::from_str(std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?) @@ -305,7 +305,7 @@ impl serde::Serialize for Attribute { } impl FromStr for Attribute { - type Err = value::Error; + type Err = Error; fn from_str(mut s: &str) -> Result { let inverted = if let Some(rest) = s.strip_prefix("no-").or_else(|| s.strip_prefix("no")) { @@ -338,7 +338,7 @@ impl FromStr for Attribute { } impl TryFrom<&BStr> for Attribute { - type Error = value::Error; + type Error = Error; fn try_from(s: &BStr) -> Result { Self::from_str(std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?) diff --git a/git-config/src/values/integer.rs b/git-config-value/src/integer.rs similarity index 95% rename from git-config/src/values/integer.rs rename to git-config-value/src/integer.rs index 0a3e729f9b0..0307c3b84ac 100644 --- a/git-config/src/values/integer.rs +++ b/git-config-value/src/integer.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, convert::TryFrom, fmt::Display, str::FromStr}; use bstr::{BStr, BString}; -use crate::{value, Integer}; +use crate::{Error, Integer}; impl Integer { /// Canonicalize values as simple decimal numbers. @@ -47,15 +47,15 @@ impl serde::Serialize for Integer { } } -fn int_err(input: impl Into) -> value::Error { - value::Error::new( +fn int_err(input: impl Into) -> Error { + Error::new( "Integers needs to be positive or negative numbers which may have a suffix like 1k, 42, or 50G", input, ) } impl TryFrom<&BStr> for Integer { - type Error = value::Error; + type Error = Error; fn try_from(s: &BStr) -> Result { let s = std::str::from_utf8(s).map_err(|err| int_err(s).with_err(err))?; @@ -80,7 +80,7 @@ impl TryFrom<&BStr> for Integer { } impl TryFrom> for Integer { - type Error = value::Error; + type Error = Error; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) diff --git a/git-config-value/src/lib.rs b/git-config-value/src/lib.rs new file mode 100644 index 00000000000..5b4f3fa76c3 --- /dev/null +++ b/git-config-value/src/lib.rs @@ -0,0 +1,47 @@ +//! Parsing for data types used in `git-config` files to allow their use from environment variables and other sources. +//! +//! ## Feature Flags +#![cfg_attr( + feature = "document-features", + cfg_attr(doc, doc = ::document_features::document_features!()) +)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![deny(missing_docs, rust_2018_idioms, unsafe_code)] + +/// The error returned when any config value couldn't be instantiated due to malformed input. +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +#[allow(missing_docs)] +#[error("Could not decode '{input}': {message}")] +pub struct Error { + pub message: &'static str, + pub input: bstr::BString, + #[source] + pub utf8_err: Option, +} + +impl Error { + /// Create a new value error from `message`, with `input` being what's causing the error. + pub fn new(message: &'static str, input: impl Into) -> Self { + Error { + message, + input: input.into(), + utf8_err: None, + } + } + + pub(crate) fn with_err(mut self, err: std::str::Utf8Error) -> Self { + self.utf8_err = Some(err); + self + } +} + +mod boolean; +/// +pub mod color; +/// +pub mod integer; +/// +pub mod path; + +mod types; +pub use types::{Boolean, Color, Integer, Path}; diff --git a/git-config/src/values/path.rs b/git-config-value/src/path.rs similarity index 100% rename from git-config/src/values/path.rs rename to git-config-value/src/path.rs diff --git a/git-config-value/src/types.rs b/git-config-value/src/types.rs new file mode 100644 index 00000000000..e5a47fe5074 --- /dev/null +++ b/git-config-value/src/types.rs @@ -0,0 +1,48 @@ +use crate::{color, integer}; + +/// Any value that may contain a foreground color, background color, a +/// collection of color (text) modifiers, or a combination of any of the +/// aforementioned values, like `red` or `brightgreen`. +/// +/// Note that `git-config` allows color values to simply be a collection of +/// [`color::Attribute`]s, and does not require a [`color::Name`] for either the +/// foreground or background color. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Color { + /// A provided foreground color + pub foreground: Option, + /// A provided background color + pub background: Option, + /// A potentially empty set of text attributes + pub attributes: color::Attribute, +} + +/// Any value that can be interpreted as an integer. +/// +/// This supports any numeric value that can fit in a [`i64`], excluding the +/// suffix. The suffix is parsed separately from the value itself, so if you +/// wish to obtain the true value of the integer, you must account for the +/// suffix after fetching the value. [`integer::Suffix`] provides +/// [`bitwise_offset()`][integer::Suffix::bitwise_offset] to help with the +/// math, or [to_decimal()][Integer::to_decimal()] for obtaining a usable value in one step. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct Integer { + /// The value, without any suffix modification + pub value: i64, + /// A provided suffix, if any. + pub suffix: Option, +} + +/// Any value that can be interpreted as a boolean. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +#[allow(missing_docs)] +pub struct Boolean(pub bool); + +/// Any value that can be interpreted as a path to a resource on disk. +/// +/// Git represents file paths as byte arrays, modeled here as owned or borrowed byte sequences. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct Path<'a> { + /// The path string, un-interpolated + pub value: std::borrow::Cow<'a, bstr::BStr>, +} diff --git a/git-config/tests/values/boolean.rs b/git-config-value/tests/value/boolean.rs similarity index 96% rename from git-config/tests/values/boolean.rs rename to git-config-value/tests/value/boolean.rs index 8a3bb80f140..e9e7ec28ed8 100644 --- a/git-config/tests/values/boolean.rs +++ b/git-config-value/tests/value/boolean.rs @@ -1,9 +1,7 @@ +use crate::b; +use git_config_value::Boolean; use std::convert::TryFrom; -use git_config::Boolean; - -use crate::value::b; - #[test] fn from_str_false() -> crate::Result { assert!(!Boolean::try_from(b("no"))?.0); diff --git a/git-config/tests/values/color.rs b/git-config-value/tests/value/color.rs similarity index 98% rename from git-config/tests/values/color.rs rename to git-config-value/tests/value/color.rs index f6cc1c0e4b1..463297cc531 100644 --- a/git-config/tests/values/color.rs +++ b/git-config-value/tests/value/color.rs @@ -1,8 +1,7 @@ mod name { + use git_config_value::color::Name; use std::str::FromStr; - use git_config::color::Name; - #[test] fn non_bright() { assert_eq!(Name::from_str("normal"), Ok(Name::Normal)); @@ -60,7 +59,7 @@ mod name { mod attribute { use std::str::FromStr; - use git_config::color::Attribute; + use git_config_value::color::Attribute; #[test] fn non_inverted() { @@ -112,7 +111,7 @@ mod from_git { use std::convert::TryFrom; use bstr::BStr; - use git_config::Color; + use git_config_value::Color; #[test] fn reset() { diff --git a/git-config/tests/values/integer.rs b/git-config-value/tests/value/integer.rs similarity index 96% rename from git-config/tests/values/integer.rs rename to git-config-value/tests/value/integer.rs index 86478d6e16e..ee95f13282a 100644 --- a/git-config/tests/values/integer.rs +++ b/git-config-value/tests/value/integer.rs @@ -1,8 +1,8 @@ use std::convert::TryFrom; -use git_config::{integer::Suffix, Integer}; - -use crate::value::b; +use crate::b; +use git_config_value::integer::Suffix; +use git_config_value::Integer; #[test] fn from_str_no_suffix() { diff --git a/git-config-value/tests/value/main.rs b/git-config-value/tests/value/main.rs new file mode 100644 index 00000000000..0097b112e55 --- /dev/null +++ b/git-config-value/tests/value/main.rs @@ -0,0 +1,16 @@ +use bstr::{BStr, ByteSlice}; +use std::borrow::Cow; + +type Result = std::result::Result>; +fn b(s: &str) -> &bstr::BStr { + s.into() +} + +pub fn cow_str(s: &str) -> Cow<'_, BStr> { + Cow::Borrowed(s.as_bytes().as_bstr()) +} + +mod boolean; +mod color; +mod integer; +mod path; diff --git a/git-config/tests/values/path.rs b/git-config-value/tests/value/path.rs similarity index 80% rename from git-config/tests/values/path.rs rename to git-config-value/tests/value/path.rs index 6ae146e5a39..a075dd5496b 100644 --- a/git-config/tests/values/path.rs +++ b/git-config-value/tests/value/path.rs @@ -1,17 +1,16 @@ mod interpolate { + use git_config_value::path; use std::{ borrow::Cow, path::{Path, PathBuf}, }; - use git_config::{path, path::interpolate::Error}; - - use crate::{file::cow_str, value::b}; + use crate::{b, cow_str}; #[test] fn backslash_is_not_special_and_they_are_not_escaping_anything() -> crate::Result { for path in ["C:\\foo\\bar", "/foo/bar"] { - let actual = git_config::Path::from(Cow::Borrowed(b(path))).interpolate(Default::default())?; + let actual = git_config_value::Path::from(Cow::Borrowed(b(path))).interpolate(Default::default())?; assert_eq!(actual, Path::new(path)); assert!( matches!(actual, Cow::Borrowed(_)), @@ -25,7 +24,7 @@ mod interpolate { fn empty_path_is_error() { assert!(matches!( interpolate_without_context(""), - Err(Error::Missing { what: "path" }) + Err(path::interpolate::Error::Missing { what: "path" }) )); } @@ -36,7 +35,7 @@ mod interpolate { let expected = std::path::PathBuf::from(format!("{}{}{}", git_install_dir, std::path::MAIN_SEPARATOR, expected)); assert_eq!( - git_config::Path::from(cow_str(val)) + git_config_value::Path::from(cow_str(val)) .interpolate(path::interpolate::Context { git_install_dir: Path::new(git_install_dir).into(), ..Default::default() @@ -54,7 +53,7 @@ mod interpolate { let path = "./%(prefix)/foo/bar"; let git_install_dir = "/tmp/git"; assert_eq!( - git_config::Path::from(Cow::Borrowed(b(path))) + git_config_value::Path::from(Cow::Borrowed(b(path))) .interpolate(path::interpolate::Context { git_install_dir: Path::new(git_install_dir).into(), ..Default::default() @@ -76,7 +75,7 @@ mod interpolate { let home = std::env::current_dir()?; let expected = home.join("user").join("bar"); assert_eq!( - git_config::Path::from(cow_str(path)) + git_config_value::Path::from(cow_str(path)) .interpolate(path::interpolate::Context { home_dir: Some(&home), home_for_user: Some(home_for_user), @@ -94,7 +93,7 @@ mod interpolate { fn tilde_with_given_user_is_unsupported_on_windows() { assert!(matches!( interpolate_without_context("~baz/foo/bar"), - Err(Error::UserInterpolationUnsupported) + Err(git_config_value::path::interpolate::Error::UserInterpolationUnsupported) )); } @@ -114,11 +113,13 @@ mod interpolate { fn interpolate_without_context( path: impl AsRef, - ) -> Result, git_config::path::interpolate::Error> { - git_config::Path::from(Cow::Owned(path.as_ref().to_owned().into())).interpolate(path::interpolate::Context { - home_for_user: Some(home_for_user), - ..Default::default() - }) + ) -> Result, git_config_value::path::interpolate::Error> { + git_config_value::Path::from(Cow::Owned(path.as_ref().to_owned().into())).interpolate( + path::interpolate::Context { + home_for_user: Some(home_for_user), + ..Default::default() + }, + ) } fn home_for_user(name: &str) -> Option { diff --git a/git-config/Cargo.toml b/git-config/Cargo.toml index 50abf60384d..598b390a1f4 100644 --- a/git-config/Cargo.toml +++ b/git-config/Cargo.toml @@ -12,11 +12,12 @@ include = ["src/**/*", "LICENSE-*", "README.md", "CHANGELOG.md"] [features] ## Data structures implement `serde::Serialize` and `serde::Deserialize`. -serde1 = ["serde", "bstr/serde1", "git-sec/serde1", "git-ref/serde1", "git-glob/serde1"] +serde1 = ["serde", "bstr/serde1", "git-sec/serde1", "git-ref/serde1", "git-glob/serde1", "git-config-value/serde1"] [dependencies] git-features = { version = "^0.22.4", path = "../git-features"} -git-path = { version = "^0.4.1", path = "../git-path" } +git-config-value = { version = "^0.7.0", path = "../git-config-value" } +git-path = { version = "^0.4.2", path = "../git-path" } git-sec = { version = "^0.3.1", path = "../git-sec" } git-ref = { version = "^0.15.4", path = "../git-ref" } git-glob = { version = "^0.3.2", path = "../git-glob" } @@ -28,13 +29,9 @@ unicode-bom = "1.1.4" bstr = { version = "0.2.13", default-features = false, features = ["std"] } serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} smallvec = "1.9.0" -bitflags = "1.3.2" document-features = { version = "0.2.0", optional = true } -[target.'cfg(not(windows))'.dependencies] -libc = "0.2" - [dev-dependencies] git-testtools = { path = "../tests/tools"} git-repository = { path = "../git-repository" } diff --git a/git-config/src/lib.rs b/git-config/src/lib.rs index 2f9e2f90c9b..b20809300bc 100644 --- a/git-config/src/lib.rs +++ b/git-config/src/lib.rs @@ -44,10 +44,9 @@ pub mod lookup; pub mod parse; /// pub mod value; -mod values; -pub use values::{color, integer, path}; +pub use git_config_value::{color, integer, path, Boolean, Color, Integer, Path}; mod types; -pub use types::{Boolean, Color, File, Integer, Path, Source}; +pub use types::{File, Source}; /// pub mod source; diff --git a/git-config/src/types.rs b/git-config/src/types.rs index 246a7d44ea5..e96ea44813e 100644 --- a/git-config/src/types.rs +++ b/git-config/src/types.rs @@ -3,9 +3,8 @@ use std::collections::{HashMap, VecDeque}; use git_features::threading::OwnShared; use crate::{ - color, file, + file, file::{Metadata, SectionBodyIdsLut, SectionId}, - integer, parse::section, }; @@ -114,50 +113,3 @@ pub struct File<'event> { /// The source of the File itself, which is attached to new sections automatically. pub(crate) meta: OwnShared, } - -/// Any value that may contain a foreground color, background color, a -/// collection of color (text) modifiers, or a combination of any of the -/// aforementioned values, like `red` or `brightgreen`. -/// -/// Note that `git-config` allows color values to simply be a collection of -/// [`color::Attribute`]s, and does not require a [`color::Name`] for either the -/// foreground or background color. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] -pub struct Color { - /// A provided foreground color - pub foreground: Option, - /// A provided background color - pub background: Option, - /// A potentially empty set of text attributes - pub attributes: color::Attribute, -} - -/// Any value that can be interpreted as an integer. -/// -/// This supports any numeric value that can fit in a [`i64`], excluding the -/// suffix. The suffix is parsed separately from the value itself, so if you -/// wish to obtain the true value of the integer, you must account for the -/// suffix after fetching the value. [`integer::Suffix`] provides -/// [`bitwise_offset()`][integer::Suffix::bitwise_offset] to help with the -/// math, or [to_decimal()][Integer::to_decimal()] for obtaining a usable value in one step. -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -pub struct Integer { - /// The value, without any suffix modification - pub value: i64, - /// A provided suffix, if any. - pub suffix: Option, -} - -/// Any value that can be interpreted as a boolean. -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -#[allow(missing_docs)] -pub struct Boolean(pub bool); - -/// Any value that can be interpreted as a path to a resource on disk. -/// -/// Git represents file paths as byte arrays, modeled here as owned or borrowed byte sequences. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -pub struct Path<'a> { - /// The path string, un-interpolated - pub value: std::borrow::Cow<'a, bstr::BStr>, -} diff --git a/git-config/src/value/mod.rs b/git-config/src/value/mod.rs index 7ce0ecb6bcd..deb3d28b7f4 100644 --- a/git-config/src/value/mod.rs +++ b/git-config/src/value/mod.rs @@ -1,28 +1,4 @@ -/// The error returned when any config value couldn't be instantiated due to malformed input. -#[derive(Debug, thiserror::Error, Eq, PartialEq)] -#[allow(missing_docs)] -#[error("Could not decode '{input}': {message}")] -pub struct Error { - pub message: &'static str, - pub input: bstr::BString, - #[source] - pub utf8_err: Option, -} - -impl Error { - pub(crate) fn new(message: &'static str, input: impl Into) -> Self { - Error { - message, - input: input.into(), - utf8_err: None, - } - } - - pub(crate) fn with_err(mut self, err: std::str::Utf8Error) -> Self { - self.utf8_err = Some(err); - self - } -} +pub use git_config_value::Error; mod normalize; pub use normalize::{normalize, normalize_bstr, normalize_bstring}; diff --git a/git-config/src/values/mod.rs b/git-config/src/values/mod.rs deleted file mode 100644 index 59908866c9b..00000000000 --- a/git-config/src/values/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod boolean; -/// -pub mod color; -/// -pub mod integer; -/// -pub mod path; diff --git a/git-config/tests/config.rs b/git-config/tests/config.rs index dc7e905de8e..874365a2d06 100644 --- a/git-config/tests/config.rs +++ b/git-config/tests/config.rs @@ -3,4 +3,3 @@ type Result = std::result::Result>; mod file; mod parse; mod value; -mod values; diff --git a/git-config/tests/value/mod.rs b/git-config/tests/value/mod.rs index 8ef588d9491..01c7618b492 100644 --- a/git-config/tests/value/mod.rs +++ b/git-config/tests/value/mod.rs @@ -1,6 +1 @@ -/// Converts string to a bstr -pub fn b(s: &str) -> &bstr::BStr { - s.into() -} - mod normalize; diff --git a/git-config/tests/values/mod.rs b/git-config/tests/values/mod.rs deleted file mode 100644 index 6692791ab32..00000000000 --- a/git-config/tests/values/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod boolean; - -mod integer; - -mod color; - -mod path; diff --git a/git-credentials/Cargo.toml b/git-credentials/Cargo.toml index 00e18316b7c..e89e862ae3c 100644 --- a/git-credentials/Cargo.toml +++ b/git-credentials/Cargo.toml @@ -16,12 +16,24 @@ serde1 = ["serde", "bstr/serde1", "git-sec/serde1"] [dependencies] git-sec = { version = "^0.3.1", path = "../git-sec" } -quick-error = "2.0.0" +git-url = { version = "^0.8.0", path = "../git-url" } +git-path = { version = "^0.4.2", path = "../git-path" } +git-command = { version = "0.1.0", path = "../git-command" } +git-config-value = { version = "^0.7.0", path = "../git-config-value" } +git-prompt = { version = "0.1.0", path = "../git-prompt" } + +thiserror = "1.0.32" serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } bstr = { version = "0.2.13", default-features = false, features = ["std"]} + + document-features = { version = "0.2.1", optional = true } +[dev-dependencies] +git-testtools = { path = "../tests/tools" } +git-sec = { path = "../git-sec" } + [package.metadata.docs.rs] all-features = true features = ["document-features"] diff --git a/git-credentials/examples/custom-helper.rs b/git-credentials/examples/custom-helper.rs new file mode 100644 index 00000000000..109e3b47de2 --- /dev/null +++ b/git-credentials/examples/custom-helper.rs @@ -0,0 +1,24 @@ +use git_credentials::{program, protocol}; + +/// Run like this `echo url=https://example.com | cargo run --example custom-helper -- get` +pub fn main() -> Result<(), git_credentials::program::main::Error> { + git_credentials::program::main( + std::env::args_os().skip(1), + std::io::stdin(), + std::io::stdout(), + |action, context| -> std::io::Result<_> { + match action { + program::main::Action::Get => Ok(Some(protocol::Context { + username: Some("user".into()), + password: Some("pass".into()), + ..context + })), + program::main::Action::Erase => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Refusing to delete credentials for demo purposes", + )), + program::main::Action::Store => Ok(None), + } + }, + ) +} diff --git a/git-credentials/examples/git-credential-lite.rs b/git-credentials/examples/git-credential-lite.rs new file mode 100644 index 00000000000..ee913f7f124 --- /dev/null +++ b/git-credentials/examples/git-credential-lite.rs @@ -0,0 +1,23 @@ +use std::convert::TryInto; + +/// Run like this `echo url=https://example.com | cargo run --example git-credential-light -- fill` +pub fn main() -> Result<(), git_credentials::program::main::Error> { + git_credentials::program::main( + std::env::args_os().skip(1), + std::io::stdin(), + std::io::stdout(), + |action, context| { + use git_credentials::program::main::Action::*; + git_credentials::helper::Cascade::default() + .invoke( + match action { + Get => git_credentials::helper::Action::Get(context), + Erase => git_credentials::helper::Action::Erase(context.to_bstring()), + Store => git_credentials::helper::Action::Store(context.to_bstring()), + }, + git_prompt::Options::default().apply_environment(true, true, true), + ) + .map(|outcome| outcome.and_then(|outcome| (&outcome.next).try_into().ok())) + }, + ) +} diff --git a/git-credentials/examples/invoke-git-credential.rs b/git-credentials/examples/invoke-git-credential.rs new file mode 100644 index 00000000000..8481f25ee55 --- /dev/null +++ b/git-credentials/examples/invoke-git-credential.rs @@ -0,0 +1,14 @@ +use std::convert::TryInto; + +/// Invokes `git credential` with the passed url as argument and prints obtained credentials. +pub fn main() -> Result<(), Box> { + let out = git_credentials::builtin(git_credentials::helper::Action::get_for_url( + std::env::args() + .nth(1) + .ok_or("First argument must be the URL to obtain credentials for")?, + ))? + .ok_or("Did not obtain credentials")?; + let ctx: git_credentials::protocol::Context = (&out.next).try_into()?; + ctx.write_to(std::io::stdout())?; + Ok(()) +} diff --git a/git-credentials/src/helper.rs b/git-credentials/src/helper.rs deleted file mode 100644 index 6b4cdc0742e..00000000000 --- a/git-credentials/src/helper.rs +++ /dev/null @@ -1,242 +0,0 @@ -use bstr::{BStr, BString}; -use std::{ - io::{self, Write}, - process::{Command, Stdio}, -}; - -use quick_error::quick_error; - -/// The result used in [`action()`]. -pub type Result = std::result::Result, Error>; - -quick_error! { - /// The error used in the [credentials helper][action()]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Io(err: io::Error) { - display("An IO error occurred while communicating to the credentials helper") - from() - source(err) - } - KeyNotFound(name: String) { - display("Could not find '{}' in output of git credentials helper", name) - } - CredentialsHelperFailed(code: Option) { - display("Credentials helper program failed with status code {:?}", code) - } - } -} - -/// The action to perform by the credentials [`action()`]. -#[derive(Clone, Debug)] -pub enum Action<'a> { - /// Provide credentials using the given repository URL (as &str) as context. - Fill(&'a BStr), - /// Approve the credentials as identified by the previous input provided as `BString`. - Approve(BString), - /// Reject the credentials as identified by the previous input provided as `BString`. - Reject(BString), -} - -impl<'a> Action<'a> { - fn is_fill(&self) -> bool { - matches!(self, Action::Fill(_)) - } - fn as_str(&self) -> &str { - match self { - Action::Approve(_) => "approve", - Action::Fill(_) => "fill", - Action::Reject(_) => "reject", - } - } -} - -/// A handle to [approve][NextAction::approve()] or [reject][NextAction::reject()] the outcome of the initial action. -#[derive(Clone, Debug)] -pub struct NextAction { - previous_output: BString, -} - -impl NextAction { - /// Approve the result of the previous [Action]. - pub fn approve(self) -> Action<'static> { - Action::Approve(self.previous_output) - } - /// Reject the result of the previous [Action]. - pub fn reject(self) -> Action<'static> { - Action::Reject(self.previous_output) - } -} - -/// The outcome of [`action()`]. -pub struct Outcome { - /// The obtained identity. - pub identity: git_sec::identity::Account, - /// A handle to the action to perform next using another call to [`action()`]. - pub next: NextAction, -} - -// TODO(sec): reimplement helper execution so it won't use the `git credential` anymore to allow enforcing our own security model. -// Currently we support more flexible configuration than downright not working at all. -/// Call the `git` credentials helper program performing the given `action`. -/// -/// Usually the first call is performed with [`Action::Fill`] to obtain an identity, which subsequently can be used. -/// On successful usage, use [`NextAction::approve()`], otherwise [`NextAction::reject()`]. -pub fn action(action: Action<'_>) -> Result { - let mut cmd = Command::new(cfg!(windows).then(|| "git.exe").unwrap_or("git")); - cmd.arg("credential") - .arg(action.as_str()) - .stdin(Stdio::piped()) - .stdout(if action.is_fill() { - Stdio::piped() - } else { - Stdio::null() - }); - let mut child = cmd.spawn()?; - let mut stdin = child.stdin.take().expect("stdin to be configured"); - - match action { - Action::Fill(url) => encode_message(url, stdin)?, - Action::Approve(last) | Action::Reject(last) => { - stdin.write_all(&last)?; - stdin.write_all(&[b'\n'])? - } - } - - let output = child.wait_with_output()?; - if !output.status.success() { - return Err(Error::CredentialsHelperFailed(output.status.code())); - } - let stdout = output.stdout; - if stdout.is_empty() { - Ok(None) - } else { - let kvs = decode_message(stdout.as_slice())?; - let find = |name: &str| { - kvs.iter() - .find(|(k, _)| k == name) - .ok_or_else(|| Error::KeyNotFound(name.into())) - .map(|(_, n)| n.to_owned()) - }; - Ok(Some(Outcome { - identity: git_sec::identity::Account { - username: find("username")?, - password: find("password")?, - }, - next: NextAction { - previous_output: stdout.into(), - }, - })) - } -} - -/// Encode `url` to `out` for consumption by a `git credentials` helper program. -pub fn encode_message(url: &BStr, mut out: impl io::Write) -> io::Result<()> { - validate(url)?; - writeln!(out, "url={}\n", url) -} - -fn validate(url: &BStr) -> io::Result<()> { - if url.contains(&0) || url.contains(&b'\n') { - return Err(io::Error::new( - io::ErrorKind::Other, - "token to encode must not contain newlines or null bytes", - )); - } - Ok(()) -} - -/// Decode all lines in `input` as key-value pairs produced by a `git credentials` helper program. -pub fn decode_message(mut input: impl io::Read) -> io::Result> { - let mut buf = String::new(); - input.read_to_string(&mut buf)?; - buf.lines() - .take_while(|l| !l.is_empty()) - .map(|l| { - let mut iter = l.splitn(2, '=').map(|s| s.to_owned()); - match (iter.next(), iter.next()) { - (Some(key), Some(value)) => validate(key.as_str().into()) - .and_then(|_| validate(value.as_str().into())) - .map(|_| (key, value)), - _ => Err(io::Error::new( - io::ErrorKind::Other, - "Invalid format, expecting key=value", - )), - } - }) - .collect::>>() -} - -#[cfg(test)] -mod tests { - use super::*; - type Result = std::result::Result<(), Box>; - - mod encode_message { - use bstr::ByteSlice; - - use super::*; - - #[test] - fn from_url() -> super::Result { - let mut out = Vec::new(); - encode_message("https://github.com/byron/gitoxide".into(), &mut out)?; - assert_eq!(out.as_bstr(), b"url=https://github.com/byron/gitoxide\n\n".as_bstr()); - Ok(()) - } - - mod invalid { - use std::io; - - use super::*; - - #[test] - fn contains_null() { - assert_eq!( - encode_message("https://foo\u{0}".into(), Vec::new()) - .err() - .map(|e| e.kind()), - Some(io::ErrorKind::Other) - ); - } - #[test] - fn contains_newline() { - assert_eq!( - encode_message("https://foo\n".into(), Vec::new()) - .err() - .map(|e| e.kind()), - Some(io::ErrorKind::Other) - ); - } - } - } - - mod decode_message { - use super::*; - - #[test] - fn typical_response() -> super::Result { - assert_eq!( - decode_message( - "protocol=https -host=example.com -username=bob -password=secr3t\n\n -this=is-skipped-past-empty-line" - .as_bytes() - )?, - vec![ - ("protocol", "https"), - ("host", "example.com"), - ("username", "bob"), - ("password", "secr3t") - ] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect::>() - ); - Ok(()) - } - } -} diff --git a/git-credentials/src/helper/cascade.rs b/git-credentials/src/helper/cascade.rs new file mode 100644 index 00000000000..9cdae70a22a --- /dev/null +++ b/git-credentials/src/helper/cascade.rs @@ -0,0 +1,146 @@ +use crate::helper::Cascade; +use crate::protocol::Context; +use crate::{helper, protocol, Program}; + +impl Default for Cascade { + fn default() -> Self { + Cascade { + programs: Vec::new(), + stderr: true, + use_http_path: false, + } + } +} + +/// Initialization +impl Cascade { + /// Return the programs to run for the current platform. + /// + /// These are typically used as basis for all credential cascade invocations, with configured programs following afterwards. + /// + /// # Note + /// + /// These defaults emulate what typical git installations may use these days, as in fact it's a configurable which comes + /// from installation-specific configuration files which we cannot know (or guess at best). + /// This seems like an acceptable trade-off as helpers are ignored if they fail or are not existing. + pub fn platform_builtin() -> Vec { + if cfg!(target_os = "macos") { + Some("osxkeychain") + } else if cfg!(target_os = "linux") { + Some("libsecret") + } else if cfg!(target_os = "windows") { + Some("manager-core") + } else { + None + } + .map(|name| vec![Program::from_custom_definition(name)]) + .unwrap_or_default() + } +} + +/// Builder +impl Cascade { + /// Extend the list of programs to run `programs`. + pub fn extend(mut self, programs: impl IntoIterator) -> Self { + self.programs.extend(programs); + self + } + /// If `toggle` is true, http(s) urls will use the path portions of the url to obtain a credential for. + /// + /// Otherwise, they will only take the user name into account. + pub fn use_http_path(mut self, toggle: bool) -> Self { + self.use_http_path = toggle; + self + } +} + +/// Finalize +impl Cascade { + /// Invoke the cascade by `invoking` each program with `action`, and configuring potential prompts with `prompt` options. + /// The latter can also be used to disable the prompt entirely when setting the `mode` to [`Disable`][git_prompt::Mode::Disable];=. + /// + /// When _getting_ credentials, all programs are asked until the credentials are complete, stopping the cascade. + /// When _storing_ or _erasing_ all programs are instructed in order. + pub fn invoke(&mut self, mut action: helper::Action, mut prompt: git_prompt::Options<'_>) -> protocol::Result { + let mut url = action + .context_mut() + .map(|ctx| ctx.destructure_url_in_place(self.use_http_path)) + .transpose()? + .and_then(|ctx| ctx.url.take()); + + for program in &mut self.programs { + program.stderr = self.stderr; + match helper::invoke::raw(program, &action) { + Ok(None) => {} + Ok(Some(stdout)) => { + let ctx = Context::from_bytes(&stdout)?; + if let Some(dst_ctx) = action.context_mut() { + if let Some(src) = ctx.path { + dst_ctx.path = Some(src); + } + for (src, dst) in [ + (ctx.protocol, &mut dst_ctx.protocol), + (ctx.host, &mut dst_ctx.host), + (ctx.username, &mut dst_ctx.username), + (ctx.password, &mut dst_ctx.password), + ] { + if let Some(src) = src { + *dst = Some(src); + } + } + if let Some(src) = ctx.url { + dst_ctx.url = Some(src); + url = dst_ctx.destructure_url_in_place(self.use_http_path)?.url.take(); + } + if dst_ctx.username.is_some() && dst_ctx.password.is_some() { + break; + } + if ctx.quit.unwrap_or_default() { + dst_ctx.quit = ctx.quit; + break; + } + } + } + Err(helper::Error::CredentialsHelperFailed { .. }) => continue, // ignore helpers that we can't call + Err(err) if action.context().is_some() => return Err(err.into()), // communication errors are fatal when getting credentials + Err(_) => {} // for other actions, ignore everything, try the operation + } + } + + if prompt.mode != git_prompt::Mode::Disable { + if let Some(ctx) = action.context_mut() { + ctx.url = url; + if ctx.username.is_none() { + let message = ctx.to_prompt("Username"); + prompt.mode = git_prompt::Mode::Visible; + ctx.username = git_prompt::ask(&message, &prompt) + .map_err(|err| protocol::Error::Prompt { + prompt: message, + source: err, + })? + .into(); + } + if ctx.password.is_none() { + let message = ctx.to_prompt("Password"); + prompt.mode = git_prompt::Mode::Hidden; + ctx.password = git_prompt::ask(&message, &prompt) + .map_err(|err| protocol::Error::Prompt { + prompt: message, + source: err, + })? + .into(); + } + } + } + + protocol::helper_outcome_to_result( + action.context().map(|ctx| helper::Outcome { + username: ctx.username.clone(), + password: ctx.password.clone(), + quit: ctx.quit.unwrap_or(false), + next: ctx.to_owned().into(), + }), + action, + ) + } +} diff --git a/git-credentials/src/helper/invoke.rs b/git-credentials/src/helper/invoke.rs new file mode 100644 index 00000000000..9d6bdac7610 --- /dev/null +++ b/git-credentials/src/helper/invoke.rs @@ -0,0 +1,66 @@ +use crate::helper::{Action, NextAction}; +use crate::helper::{Context, Error, Outcome, Result}; +use std::io::Read; + +impl Action { + /// Send ourselves to the given `write` which is expected to be credentials-helper compatible + pub fn send(&self, mut write: impl std::io::Write) -> std::io::Result<()> { + match self { + Action::Get(ctx) => ctx.write_to(write), + Action::Store(last) | Action::Erase(last) => { + write.write_all(last)?; + write.write_all(&[b'\n']) + } + } + } +} + +/// Invoke the given `helper` with `action` in `context`. +/// +/// Usually the first call is performed with [`Action::Get`] to obtain `Some` identity, which subsequently can be used if it is complete. +/// Note that it may also only contain the username _or_ password, and should start out with everything the helper needs. +/// On successful usage, use [`NextAction::store()`], otherwise [`NextAction::erase()`], which is when this function +/// returns `Ok(None)` as no outcome is expected. +pub fn invoke(helper: &mut crate::Program, action: &Action) -> Result { + match raw(helper, action)? { + None => Ok(None), + Some(stdout) => { + let ctx = Context::from_bytes(stdout.as_slice())?; + Ok(Some(Outcome { + username: ctx.username, + password: ctx.password, + quit: ctx.quit.unwrap_or(false), + next: NextAction { + previous_output: stdout.into(), + }, + })) + } + } +} + +pub(crate) fn raw(helper: &mut crate::Program, action: &Action) -> std::result::Result>, Error> { + let (stdin, stdout) = helper.start(action)?; + if let (Action::Get(_), None) = (&action, &stdout) { + panic!("BUG: `Helper` impls must return an output handle to read output from if Action::Get is provided") + } + action.send(stdin)?; + let stdout = stdout + .map(|mut stdout| { + let mut buf = Vec::new(); + stdout.read_to_end(&mut buf).map(|_| buf) + }) + .transpose() + .map_err(|err| Error::CredentialsHelperFailed { source: err })?; + helper.finish().map_err(|err| { + if err.kind() == std::io::ErrorKind::Other { + Error::CredentialsHelperFailed { source: err } + } else { + err.into() + } + })?; + + match matches!(action, Action::Get(_)).then(|| stdout).flatten() { + None => Ok(None), + Some(stdout) => Ok(Some(stdout)), + } +} diff --git a/git-credentials/src/helper/mod.rs b/git-credentials/src/helper/mod.rs new file mode 100644 index 00000000000..743e2dedda3 --- /dev/null +++ b/git-credentials/src/helper/mod.rs @@ -0,0 +1,168 @@ +use crate::protocol::Context; +use crate::{protocol, Program}; +use bstr::{BStr, BString}; +use std::convert::TryFrom; + +/// A list of helper programs to run in order to obtain credentials. +#[allow(dead_code)] +#[derive(Debug)] +pub struct Cascade { + /// The programs to run in order to obtain credentials + pub programs: Vec, + /// If true, stderr is enabled when `programs` are run, which is the default. + pub stderr: bool, + /// If true, http(s) urls will take their path portion into account when obtaining credentials. Default is false. + pub use_http_path: bool, +} + +/// The outcome of the credentials helper [invocation][crate::helper::invoke()]. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Outcome { + /// The username to use in the identity, if set. + pub username: Option, + /// The username to use in the identity, if set. + pub password: Option, + /// If set, the helper asked to stop the entire process, whether the identity is complete or not. + pub quit: bool, + /// A handle to the action to perform next in another call to [`helper::invoke()`][crate::helper::invoke()]. + pub next: NextAction, +} + +impl Outcome { + /// Try to fetch username _and_ password to form an identity. This will fail if one of them is not set. + /// + /// This does nothing if only one of the fields is set, or consume both. + pub fn consume_identity(&mut self) -> Option { + if self.username.is_none() || self.password.is_none() { + return None; + } + self.username + .take() + .zip(self.password.take()) + .map(|(username, password)| git_sec::identity::Account { username, password }) + } +} + +/// The Result type used in [`invoke()`][crate::helper::invoke()]. +pub type Result = std::result::Result, Error>; + +/// The error used in the [credentials helper invocation][crate::helper::invoke()]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + ContextDecode(#[from] protocol::context::decode::Error), + #[error("An IO error occurred while communicating to the credentials helper")] + Io(#[from] std::io::Error), + #[error(transparent)] + CredentialsHelperFailed { source: std::io::Error }, +} + +/// The action to perform by the credentials [helper][`crate::helper::invoke()`]. +#[derive(Clone, Debug)] +pub enum Action { + /// Provide credentials using the given repository context, which must include the repository url. + Get(Context), + /// Approve the credentials as identified by the previous input provided as `BString`, containing information from [`Context`]. + Store(BString), + /// Reject the credentials as identified by the previous input provided as `BString`. containing information from [`Context`]. + Erase(BString), +} + +/// Initialization +impl Action { + /// Create a `Get` action with context containing the given URL + pub fn get_for_url(url: impl Into) -> Action { + Action::Get(Context { + url: Some(url.into()), + ..Default::default() + }) + } +} + +/// Access +impl Action { + /// Return the payload of store or erase actions. + pub fn payload(&self) -> Option<&BStr> { + use bstr::ByteSlice; + match self { + Action::Get(_) => None, + Action::Store(p) | Action::Erase(p) => Some(p.as_bstr()), + } + } + /// Return the context of a get operation, or `None`. + /// + /// The opposite of [`payload`][Action::payload()]. + pub fn context(&self) -> Option<&Context> { + match self { + Action::Get(ctx) => Some(ctx), + Action::Erase(_) | Action::Store(_) => None, + } + } + + /// Return the mutable context of a get operation, or `None`. + pub fn context_mut(&mut self) -> Option<&mut Context> { + match self { + Action::Get(ctx) => Some(ctx), + Action::Erase(_) | Action::Store(_) => None, + } + } + + /// Returns true if this action expects output from the helper. + pub fn expects_output(&self) -> bool { + matches!(self, Action::Get(_)) + } + + /// The name of the argument to describe this action. If `is_external` is true, the target program is + /// a custom credentials helper, not a built-in one. + pub fn as_arg(&self, is_external: bool) -> &str { + match self { + Action::Get(_) if is_external => "get", + Action::Get(_) => "fill", + Action::Store(_) if is_external => "store", + Action::Store(_) => "approve", + Action::Erase(_) if is_external => "erase", + Action::Erase(_) => "reject", + } + } +} + +/// A handle to [store][NextAction::store()] or [erase][NextAction::erase()] the outcome of the initial action. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NextAction { + previous_output: BString, +} + +impl TryFrom<&NextAction> for Context { + type Error = protocol::context::decode::Error; + + fn try_from(value: &NextAction) -> std::result::Result { + Context::from_bytes(value.previous_output.as_ref()) + } +} + +impl From for NextAction { + fn from(ctx: Context) -> Self { + let mut buf = Vec::::new(); + ctx.write_to(&mut buf).expect("cannot fail"); + NextAction { + previous_output: buf.into(), + } + } +} + +impl NextAction { + /// Approve the result of the previous [Action] and store for lookup. + pub fn store(self) -> Action { + Action::Store(self.previous_output) + } + /// Reject the result of the previous [Action] and erase it as to not be returned when being looked up. + pub fn erase(self) -> Action { + Action::Erase(self.previous_output) + } +} + +mod cascade; +pub(crate) mod invoke; + +pub use invoke::invoke; diff --git a/git-credentials/src/lib.rs b/git-credentials/src/lib.rs index 4e543392a88..b5eaf340cb4 100644 --- a/git-credentials/src/lib.rs +++ b/git-credentials/src/lib.rs @@ -9,6 +9,34 @@ #![deny(missing_docs, rust_2018_idioms)] #![forbid(unsafe_code)] +/// A program/executable implementing the credential helper protocol. +#[derive(Debug)] +pub struct Program { + /// The kind of program, ready for launch. + pub kind: program::Kind, + /// If true, stderr is enabled, which is the default. + pub stderr: bool, + /// `Some(…)` if the process is running. + child: Option, +} + /// pub mod helper; -pub use helper::action as helper; + +/// +pub mod program; + +/// +pub mod protocol; + +/// Call the `git credential` helper program performing the given `action`, which reads all context from the git configuration +/// and does everything `git` typically does. The `action` should have been created with [`helper::Action::get_for_url()`] to +/// contain only the URL to kick off the process, or should be created by [`helper::NextAction`]. +/// +/// If more control is required, use the [`Cascade`][helper::Cascade] type. +pub fn builtin(action: helper::Action) -> protocol::Result { + protocol::helper_outcome_to_result( + helper::invoke(&mut Program::from_kind(program::Kind::Builtin), &action)?, + action, + ) +} diff --git a/git-credentials/src/program/main.rs b/git-credentials/src/program/main.rs new file mode 100644 index 00000000000..ea67ee7a2aa --- /dev/null +++ b/git-credentials/src/program/main.rs @@ -0,0 +1,108 @@ +use bstr::BString; +use std::convert::TryFrom; +use std::ffi::OsString; + +/// The action passed to the credential helper implementation in [`main()`][crate::program::main()]. +#[derive(Debug, Copy, Clone)] +pub enum Action { + /// Get credentials for a url. + Get, + /// Store credentials provided in the given context. + Store, + /// Erase credentials identified by the given context. + Erase, +} + +impl TryFrom for Action { + type Error = Error; + + fn try_from(value: OsString) -> Result { + Ok(match value.to_str() { + Some("fill") | Some("get") => Action::Get, + Some("approve") | Some("store") => Action::Store, + Some("reject") | Some("erase") => Action::Erase, + _ => return Err(Error::ActionInvalid { name: value }), + }) + } +} + +impl Action { + /// Return ourselves as string representation, similar to what would be passed as argument to a credential helper. + pub fn as_str(&self) -> &'static str { + match self { + Action::Get => "get", + Action::Store => "store", + Action::Erase => "erase", + } + } +} + +/// The error of [`main()`][crate::program::main()]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Action named {name:?} is invalid, need 'get', 'store', 'erase' or 'fill', 'approve', 'reject'")] + ActionInvalid { name: OsString }, + #[error("The first argument must be the action to perform")] + ActionMissing, + #[error(transparent)] + Helper { + source: Box, + }, + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Context(#[from] crate::protocol::context::decode::Error), + #[error("Credentials for {url:?} could not be obtained")] + CredentialsMissing { url: BString }, + #[error("The url wasn't provided in input - the git credentials protocol mandates this")] + UrlMissing, +} + +pub(crate) mod function { + use crate::program::main::{Action, Error}; + use crate::protocol::Context; + use std::convert::TryInto; + use std::ffi::OsString; + + /// Invoke a custom credentials helper which receives program `args`, with the first argument being the + /// action to perform (as opposed to the program name). + /// Then read context information from `stdin` and if the action is `Action::Get`, then write the result to `stdout`. + /// `credentials` is the API version of such call, where`Ok(Some(context))` returns credentials, and `Ok(None)` indicates + /// no credentials could be found for `url`, which is always set when called. + /// + /// Call this function from a programs `main`, passing `std::env::args_os()`, `stdin()` and `stdout` accordingly, along with + /// your own helper implementation. + pub fn main( + args: impl IntoIterator, + mut stdin: impl std::io::Read, + stdout: impl std::io::Write, + credentials: CredentialsFn, + ) -> Result<(), Error> + where + CredentialsFn: FnOnce(Action, Context) -> Result, E>, + E: std::error::Error + Send + Sync + 'static, + { + let action: Action = args.into_iter().next().ok_or(Error::ActionMissing)?.try_into()?; + let mut buf = Vec::::with_capacity(512); + stdin.read_to_end(&mut buf)?; + let ctx = Context::from_bytes(&buf)?; + if ctx.url.is_none() { + return Err(Error::UrlMissing); + } + let res = credentials(action, ctx).map_err(|err| Error::Helper { source: Box::new(err) })?; + match (action, res) { + (Action::Get, None) => { + return Err(Error::CredentialsMissing { + url: Context::from_bytes(&buf)?.url.expect("present and checked above"), + }) + } + (Action::Get, Some(ctx)) => ctx.write_to(stdout)?, + (Action::Erase | Action::Store, None) => {} + (Action::Erase | Action::Store, Some(_)) => { + panic!("BUG: credentials helper must not return context for erase or store actions") + } + } + Ok(()) + } +} diff --git a/git-credentials/src/program/mod.rs b/git-credentials/src/program/mod.rs new file mode 100644 index 00000000000..c60cd45fe61 --- /dev/null +++ b/git-credentials/src/program/mod.rs @@ -0,0 +1,129 @@ +use crate::{helper, Program}; +use bstr::{BString, ByteSlice, ByteVec}; +use std::process::{Command, Stdio}; + +/// The kind of helper program to use. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Kind { + /// The built-in `git credential` helper program, part of any `git` distribution. + Builtin, + /// A custom credentials helper, as identified just by the name with optional arguments + ExternalName { + /// The name like `foo` along with optional args, like `foo --arg --bar="a b"`, with arguments using `sh` shell quoting rules. + /// The program executed will be `git-credential-foo` if `name_and_args` starts with `foo`. + name_and_args: BString, + }, + /// A custom credentials helper, as identified just by the absolute path to the program and optional arguments. The program is executed through a shell. + ExternalPath { + /// The absolute path to the executable, like `/path/to/exe` along with optional args, like `/path/to/exe --arg --bar="a b"`, with arguments using `sh` + /// shell quoting rules. + path_and_args: BString, + }, + /// A script to execute with `sh`. + ExternalShellScript(BString), +} + +/// Initialization +impl Program { + /// Create a new program of the given `kind`. + pub fn from_kind(kind: Kind) -> Self { + Program { + kind, + child: None, + stderr: true, + } + } + + /// Parse the given input as per the custom helper definition, supporting `!