mirror of
https://github.com/awfixers-stuff/src.git
synced 2026-03-26 20:46:00 +00:00
create src
This commit is contained in:
1727
src-credentials/CHANGELOG.md
Normal file
1727
src-credentials/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
52
src-credentials/Cargo.toml
Normal file
52
src-credentials/Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
lints.workspace = true
|
||||
|
||||
[package]
|
||||
name = "src-credentials"
|
||||
version = "0.37.0"
|
||||
repository = "https://github.com/GitoxideLabs/gitoxide"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "A crate of the gitoxide project to interact with git credentials helpers"
|
||||
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
include = ["src/**/*", "LICENSE-*"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
## Data structures implement `serde::Serialize` and `serde::Deserialize`.
|
||||
serde = ["dep:serde", "bstr/serde", "src-sec/serde"]
|
||||
|
||||
[dependencies]
|
||||
src-sec = { version = "^0.13.1", path = "../src-sec" }
|
||||
src-url = { version = "^0.35.2", path = "../src-url" }
|
||||
src-path = { version = "^0.11.1", path = "../src-path" }
|
||||
src-command = { version = "^0.8.0", path = "../src-command" }
|
||||
src-config-value = { version = "^0.17.1", path = "../src-config-value" }
|
||||
src-prompt = { version = "^0.14.0", path = "../src-prompt" }
|
||||
src-date = { version = "^0.15.0", path = "../src-date" }
|
||||
src-trace = { version = "^0.1.18", path = "../src-trace" }
|
||||
|
||||
thiserror = "2.0.18"
|
||||
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
|
||||
bstr = { version = "1.12.0", default-features = false, features = ["std"] }
|
||||
|
||||
|
||||
|
||||
document-features = { version = "0.2.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
src-quote = { path = "../src-quote" }
|
||||
src-sec = { path = "../src-sec" }
|
||||
src-testtools = { path = "../tests/tools" }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
features = ["document-features"]
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = [
|
||||
# Public optional `serde` feature is retained for downstream feature compatibility.
|
||||
"serde",
|
||||
]
|
||||
1
src-credentials/LICENSE-APACHE
Symbolic link
1
src-credentials/LICENSE-APACHE
Symbolic link
@@ -0,0 +1 @@
|
||||
../LICENSE-APACHE
|
||||
1
src-credentials/LICENSE-MIT
Symbolic link
1
src-credentials/LICENSE-MIT
Symbolic link
@@ -0,0 +1 @@
|
||||
../LICENSE-MIT
|
||||
23
src-credentials/examples/custom-helper.rs
Normal file
23
src-credentials/examples/custom-helper.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use gix_credentials::{program, protocol};
|
||||
|
||||
/// Run like this `echo url=https://example.com | cargo run --example custom-helper -- get`
|
||||
pub fn main() -> Result<(), gix_credentials::program::main::Error> {
|
||||
gix_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::other(
|
||||
"Refusing to delete credentials for demo purposes",
|
||||
)),
|
||||
program::main::Action::Store => Ok(None),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
21
src-credentials/examples/git-credential-lite.rs
Normal file
21
src-credentials/examples/git-credential-lite.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
/// Run like this `echo url=https://example.com | cargo run --example git-credential-light -- fill`
|
||||
pub fn main() -> Result<(), gix_credentials::program::main::Error> {
|
||||
gix_credentials::program::main(
|
||||
std::env::args_os().skip(1),
|
||||
std::io::stdin(),
|
||||
std::io::stdout(),
|
||||
|action, context| {
|
||||
use gix_credentials::program::main::Action::*;
|
||||
gix_credentials::helper::Cascade::default()
|
||||
.invoke(
|
||||
match action {
|
||||
Get => gix_credentials::helper::Action::Get(context),
|
||||
Erase => gix_credentials::helper::Action::Erase(context.to_bstring()),
|
||||
Store => gix_credentials::helper::Action::Store(context.to_bstring()),
|
||||
},
|
||||
gix_prompt::Options::default().apply_environment(true, true, true),
|
||||
)
|
||||
.map(|outcome| outcome.and_then(|outcome| (&outcome.next).try_into().ok()))
|
||||
},
|
||||
)
|
||||
}
|
||||
12
src-credentials/examples/invoke-git-credential.rs
Normal file
12
src-credentials/examples/invoke-git-credential.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Invokes `git credential` with the passed url as argument and prints obtained credentials.
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out = gix_credentials::builtin(gix_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: gix_credentials::protocol::Context = (&out.next).try_into()?;
|
||||
ctx.write_to(std::io::stdout())?;
|
||||
Ok(())
|
||||
}
|
||||
184
src-credentials/src/helper/cascade.rs
Normal file
184
src-credentials/src/helper/cascade.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use crate::{helper, helper::Cascade, protocol, protocol::Context, Program};
|
||||
|
||||
impl Default for Cascade {
|
||||
fn default() -> Self {
|
||||
Cascade {
|
||||
programs: Vec::new(),
|
||||
stderr: true,
|
||||
use_http_path: false,
|
||||
query_user_only: 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<Program> {
|
||||
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<Item = Program>) -> 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
|
||||
}
|
||||
|
||||
/// If `toggle` is true, a bogus password will be provided to prevent any helper program from prompting for it, nor will
|
||||
/// we prompt for the password. The resulting identity will have a bogus password and it's expected to not be used by the
|
||||
/// consuming transport.
|
||||
pub fn query_user_only(mut self, toggle: bool) -> Self {
|
||||
self.query_user_only = 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`][gix_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.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn invoke(&mut self, mut action: helper::Action, mut prompt: gix_prompt::Options<'_>) -> protocol::Result {
|
||||
let mut url = action
|
||||
.context_mut()
|
||||
.map(|ctx| {
|
||||
#[allow(clippy::manual_inspect)] /* false positive */
|
||||
ctx.destructure_url_in_place(self.use_http_path).map(|ctx| {
|
||||
if self.query_user_only && ctx.password.is_none() {
|
||||
ctx.password = Some("".into());
|
||||
}
|
||||
ctx
|
||||
})
|
||||
})
|
||||
.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 Context {
|
||||
protocol,
|
||||
host,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
oauth_refresh_token,
|
||||
password_expiry_utc,
|
||||
url: ctx_url,
|
||||
quit,
|
||||
} = Context::from_bytes(&stdout)?;
|
||||
if let Some(dst_ctx) = action.context_mut() {
|
||||
if let Some(src) = path {
|
||||
dst_ctx.path = Some(src);
|
||||
}
|
||||
if let Some(src) = password_expiry_utc {
|
||||
dst_ctx.password_expiry_utc = Some(src);
|
||||
}
|
||||
for (src, dst) in [
|
||||
(protocol, &mut dst_ctx.protocol),
|
||||
(host, &mut dst_ctx.host),
|
||||
(username, &mut dst_ctx.username),
|
||||
(password, &mut dst_ctx.password),
|
||||
(oauth_refresh_token, &mut dst_ctx.oauth_refresh_token),
|
||||
] {
|
||||
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
|
||||
.password_expiry_utc
|
||||
.is_some_and(|expiry_date| expiry_date < gix_date::Time::now_utc().seconds)
|
||||
{
|
||||
dst_ctx.password_expiry_utc = None;
|
||||
dst_ctx.clear_secrets();
|
||||
}
|
||||
if dst_ctx.username.is_some() && dst_ctx.password.is_some() {
|
||||
break;
|
||||
}
|
||||
if quit.unwrap_or_default() {
|
||||
dst_ctx.quit = 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 != gix_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 = gix_prompt::Mode::Visible;
|
||||
ctx.username = gix_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 = gix_prompt::Mode::Hidden;
|
||||
ctx.password = gix_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(),
|
||||
oauth_refresh_token: ctx.oauth_refresh_token.clone(),
|
||||
quit: ctx.quit.unwrap_or(false),
|
||||
next: ctx.to_owned().into(),
|
||||
}),
|
||||
action,
|
||||
)
|
||||
}
|
||||
}
|
||||
69
src-credentials/src/helper/invoke.rs
Normal file
69
src-credentials/src/helper/invoke.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::io::Read;
|
||||
|
||||
use crate::helper::{Action, Context, Error, NextAction, Outcome, Result};
|
||||
|
||||
impl Action {
|
||||
/// Send ourselves to the given `write` which is expected to be credentials-helper compatible
|
||||
pub fn send(&self, write: &mut dyn 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).ok();
|
||||
write.write_all(b"\n").ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
oauth_refresh_token: ctx.oauth_refresh_token,
|
||||
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<Option<Vec<u8>>, Error> {
|
||||
let (mut 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(&mut stdin)?;
|
||||
drop(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)),
|
||||
}
|
||||
}
|
||||
178
src-credentials/src/helper/mod.rs
Normal file
178
src-credentials/src/helper/mod.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use bstr::{BStr, BString};
|
||||
|
||||
use crate::{protocol, protocol::Context, Program};
|
||||
|
||||
/// 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<Program>,
|
||||
/// 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.
|
||||
/// Other protocols like ssh will always use the path portion.
|
||||
pub use_http_path: bool,
|
||||
/// If true, default false, when getting credentials, we will set a bogus password to only obtain the user name.
|
||||
/// Storage and cancellation work the same, but without a password set.
|
||||
pub query_user_only: 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<String>,
|
||||
/// The password to use in the identity, if set.
|
||||
pub password: Option<String>,
|
||||
/// An OAuth refresh token that may accompany a password. It is to be treated confidentially, just like the password.
|
||||
pub oauth_refresh_token: Option<String>,
|
||||
/// 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<gix_sec::identity::Account> {
|
||||
if self.username.is_none() || self.password.is_none() {
|
||||
return None;
|
||||
}
|
||||
self.username
|
||||
.take()
|
||||
.zip(self.password.take())
|
||||
.map(|(username, password)| gix_sec::identity::Account {
|
||||
username,
|
||||
password,
|
||||
oauth_refresh_token: self.oauth_refresh_token.take(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The Result type used in [`invoke()`][crate::helper::invoke()].
|
||||
pub type Result = std::result::Result<Option<Outcome>, 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.
|
||||
/// Note that this creates an `Action` suitable for the credential helper cascade only.
|
||||
pub fn get_for_url(url: impl Into<BString>) -> 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<Self, Self::Error> {
|
||||
Context::from_bytes(value.previous_output.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Context> for NextAction {
|
||||
fn from(ctx: Context) -> Self {
|
||||
let mut buf = Vec::<u8>::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;
|
||||
43
src-credentials/src/lib.rs
Normal file
43
src-credentials/src/lib.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Interact with git credentials in various ways and launch helper programs.
|
||||
//!
|
||||
//! ## Feature Flags
|
||||
#![cfg_attr(
|
||||
all(doc, feature = "document-features"),
|
||||
doc = ::document_features::document_features!()
|
||||
)]
|
||||
#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
|
||||
#![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<std::process::Child>,
|
||||
}
|
||||
|
||||
///
|
||||
pub mod 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.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn builtin(action: helper::Action) -> protocol::Result {
|
||||
protocol::helper_outcome_to_result(
|
||||
helper::invoke(&mut Program::from_kind(program::Kind::Builtin), &action)?,
|
||||
action,
|
||||
)
|
||||
}
|
||||
114
src-credentials/src/program/main.rs
Normal file
114
src-credentials/src/program/main.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use std::ffi::OsString;
|
||||
|
||||
use bstr::BString;
|
||||
|
||||
/// 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<OsString> for Action {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: OsString) -> Result<Self, Self::Error> {
|
||||
Ok(match value.to_str() {
|
||||
Some("fill" | "get") => Action::Get,
|
||||
Some("approve" | "store") => Action::Store,
|
||||
Some("reject" | "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<dyn std::error::Error + Send + Sync + 'static>,
|
||||
},
|
||||
#[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("Either 'url' field or both 'protocol' and 'host' fields must be provided")]
|
||||
UrlMissing,
|
||||
}
|
||||
|
||||
pub(crate) mod function {
|
||||
use std::ffi::OsString;
|
||||
|
||||
use crate::{
|
||||
program::main::{Action, Error},
|
||||
protocol::Context,
|
||||
};
|
||||
|
||||
/// 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<CredentialsFn, E>(
|
||||
args: impl IntoIterator<Item = OsString>,
|
||||
mut stdin: impl std::io::Read,
|
||||
stdout: impl std::io::Write,
|
||||
credentials: CredentialsFn,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
CredentialsFn: FnOnce(Action, Context) -> Result<Option<Context>, 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::<u8>::with_capacity(512);
|
||||
stdin.read_to_end(&mut buf)?;
|
||||
let ctx = Context::from_bytes(&buf)?;
|
||||
if ctx.url.is_none() && (ctx.protocol.is_none() || ctx.host.is_none()) {
|
||||
return Err(Error::UrlMissing);
|
||||
}
|
||||
let res = credentials(action, ctx.clone()).map_err(|err| Error::Helper { source: Box::new(err) })?;
|
||||
match (action, res) {
|
||||
(Action::Get, None) => {
|
||||
let ctx_for_error = ctx;
|
||||
let url = ctx_for_error
|
||||
.url
|
||||
.clone()
|
||||
.or_else(|| ctx_for_error.to_url())
|
||||
.expect("URL is available either directly or via protocol+host which we checked for");
|
||||
return Err(Error::CredentialsMissing { url });
|
||||
}
|
||||
(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(())
|
||||
}
|
||||
}
|
||||
148
src-credentials/src/program/mod.rs
Normal file
148
src-credentials/src/program/mod.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use bstr::{BString, ByteSlice, ByteVec};
|
||||
|
||||
use crate::{helper, Program};
|
||||
|
||||
/// 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 [args]` if `name_and_args` starts with `foo [args]`.
|
||||
/// Note that a shell is only used if it's needed.
|
||||
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 `!<script>`, `name` and `/absolute/name`, the latter two
|
||||
/// also support arguments which are ignored here.
|
||||
pub fn from_custom_definition(input: impl Into<BString>) -> Self {
|
||||
fn from_custom_definition_inner(mut input: BString) -> Program {
|
||||
let kind = if input.starts_with(b"!") {
|
||||
input.remove(0);
|
||||
Kind::ExternalShellScript(input)
|
||||
} else {
|
||||
let path = gix_path::from_bstr(
|
||||
input
|
||||
.find_byte(b' ')
|
||||
.map_or(input.as_slice(), |pos| &input[..pos])
|
||||
.as_bstr(),
|
||||
);
|
||||
if gix_path::is_absolute(path) {
|
||||
Kind::ExternalPath { path_and_args: input }
|
||||
} else {
|
||||
Kind::ExternalName { name_and_args: input }
|
||||
}
|
||||
};
|
||||
Program {
|
||||
kind,
|
||||
child: None,
|
||||
stderr: true,
|
||||
}
|
||||
}
|
||||
from_custom_definition_inner(input.into())
|
||||
}
|
||||
|
||||
/// Convert the program into the respective command, suitable to invoke `action`.
|
||||
pub fn to_command(&self, action: &helper::Action) -> std::process::Command {
|
||||
let git_program = gix_path::env::exe_invocation();
|
||||
let mut cmd = match &self.kind {
|
||||
Kind::Builtin => {
|
||||
let mut cmd = Command::from(gix_command::prepare(git_program));
|
||||
cmd.arg("credential").arg(action.as_arg(false));
|
||||
cmd
|
||||
}
|
||||
Kind::ExternalName { name_and_args } => {
|
||||
let mut args = name_and_args.clone();
|
||||
args.insert_str(0, "credential-");
|
||||
args.insert_str(0, " ");
|
||||
args.insert_str(0, git_program.to_string_lossy().as_ref());
|
||||
gix_command::prepare(gix_path::from_bstr(args.as_bstr()).into_owned())
|
||||
.arg(action.as_arg(true))
|
||||
.command_may_be_shell_script_allow_manual_argument_splitting()
|
||||
.into()
|
||||
}
|
||||
Kind::ExternalShellScript(for_shell)
|
||||
| Kind::ExternalPath {
|
||||
path_and_args: for_shell,
|
||||
} => gix_command::prepare(gix_path::from_bstr(for_shell.as_bstr()).as_ref())
|
||||
.command_may_be_shell_script()
|
||||
.arg(action.as_arg(true))
|
||||
.into(),
|
||||
};
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(if action.expects_output() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
})
|
||||
.stderr(if self.stderr { Stdio::inherit() } else { Stdio::null() });
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder
|
||||
impl Program {
|
||||
/// By default `stderr` of programs is inherited and typically displayed in the terminal.
|
||||
pub fn suppress_stderr(mut self) -> Self {
|
||||
self.stderr = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Program {
|
||||
pub(crate) fn start(
|
||||
&mut self,
|
||||
action: &helper::Action,
|
||||
) -> std::io::Result<(std::process::ChildStdin, Option<std::process::ChildStdout>)> {
|
||||
assert!(self.child.is_none(), "BUG: must not call `start()` twice");
|
||||
let mut cmd = self.to_command(action);
|
||||
gix_trace::debug!(cmd = ?cmd, "launching credential helper");
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdin = child.stdin.take().expect("stdin to be configured");
|
||||
let stdout = child.stdout.take();
|
||||
|
||||
self.child = child.into();
|
||||
Ok((stdin, stdout))
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self) -> std::io::Result<()> {
|
||||
let mut child = self.child.take().expect("Call `start()` before calling finish()");
|
||||
let status = child.wait()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"Credentials helper program failed with status code {:?}",
|
||||
status.code()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
pub mod main;
|
||||
pub use main::function::main;
|
||||
125
src-credentials/src/protocol/context/mod.rs
Normal file
125
src-credentials/src/protocol/context/mod.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use bstr::BString;
|
||||
|
||||
/// Indicates key or values contain errors that can't be encoded.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(missing_docs)]
|
||||
pub enum Error {
|
||||
#[error("{key:?}={value:?} must not contain null bytes or newlines neither in key nor in value.")]
|
||||
Encoding { key: String, value: BString },
|
||||
}
|
||||
|
||||
mod access {
|
||||
use bstr::BString;
|
||||
|
||||
use crate::protocol::Context;
|
||||
|
||||
impl Context {
|
||||
/// Clear all fields that are considered secret.
|
||||
pub fn clear_secrets(&mut self) {
|
||||
let Context {
|
||||
protocol: _,
|
||||
host: _,
|
||||
path: _,
|
||||
username: _,
|
||||
password,
|
||||
oauth_refresh_token,
|
||||
password_expiry_utc: _,
|
||||
url: _,
|
||||
quit: _,
|
||||
} = self;
|
||||
|
||||
*password = None;
|
||||
*oauth_refresh_token = None;
|
||||
}
|
||||
/// Replace existing secrets with the word `<redacted>`.
|
||||
pub fn redacted(mut self) -> Self {
|
||||
let Context {
|
||||
protocol: _,
|
||||
host: _,
|
||||
path: _,
|
||||
username: _,
|
||||
password,
|
||||
oauth_refresh_token,
|
||||
password_expiry_utc: _,
|
||||
url: _,
|
||||
quit: _,
|
||||
} = &mut self;
|
||||
for secret in [password, oauth_refresh_token].into_iter().flatten() {
|
||||
*secret = "<redacted>".into();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert all relevant fields into a URL for consumption.
|
||||
pub fn to_url(&self) -> Option<BString> {
|
||||
use bstr::{ByteSlice, ByteVec};
|
||||
let mut buf: BString = self.protocol.clone()?.into();
|
||||
buf.push_str(b"://");
|
||||
if let Some(user) = &self.username {
|
||||
buf.push_str(user);
|
||||
buf.push(b'@');
|
||||
}
|
||||
if let Some(host) = &self.host {
|
||||
buf.push_str(host);
|
||||
}
|
||||
if let Some(path) = &self.path {
|
||||
if !path.starts_with_str("/") {
|
||||
buf.push(b'/');
|
||||
}
|
||||
buf.push_str(path);
|
||||
}
|
||||
buf.into()
|
||||
}
|
||||
/// Compute a prompt to obtain the given value.
|
||||
pub fn to_prompt(&self, field: &str) -> String {
|
||||
match self.to_url() {
|
||||
Some(url) => format!("{field} for {url}: "),
|
||||
None => format!("{field}: "),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod mutate {
|
||||
use bstr::ByteSlice;
|
||||
|
||||
use crate::{protocol, protocol::Context};
|
||||
|
||||
/// In-place mutation
|
||||
impl Context {
|
||||
/// Destructure the url at our `url` field into parts like protocol, host, username and path and store
|
||||
/// them in our respective fields. If `use_http_path` is set, http paths are significant even though
|
||||
/// normally this isn't the case.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn destructure_url_in_place(&mut self, use_http_path: bool) -> Result<&mut Self, protocol::Error> {
|
||||
if self.url.is_none() {
|
||||
self.url = Some(self.to_url().ok_or(protocol::Error::UrlMissing)?);
|
||||
}
|
||||
|
||||
let url = gix_url::parse(self.url.as_ref().expect("URL is present after check above").as_ref())?;
|
||||
self.protocol = Some(url.scheme.as_str().into());
|
||||
self.username = url.user().map(ToOwned::to_owned);
|
||||
self.password = url.password().map(ToOwned::to_owned);
|
||||
self.host = url.host().map(ToOwned::to_owned).map(|mut host| {
|
||||
let port = url.port.filter(|port| {
|
||||
url.scheme
|
||||
.default_port()
|
||||
.is_none_or(|default_port| *port != default_port)
|
||||
});
|
||||
if let Some(port) = port {
|
||||
use std::fmt::Write;
|
||||
write!(host, ":{port}").expect("infallible");
|
||||
}
|
||||
host
|
||||
});
|
||||
if !matches!(url.scheme, gix_url::Scheme::Http | gix_url::Scheme::Https) || use_http_path {
|
||||
let path = url.path.trim_with(|b| b == '/');
|
||||
self.path = (!path.is_empty()).then(|| path.into());
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod serde;
|
||||
pub use self::serde::decode;
|
||||
154
src-credentials/src/protocol/context/serde.rs
Normal file
154
src-credentials/src/protocol/context/serde.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use bstr::BStr;
|
||||
|
||||
use crate::protocol::context::Error;
|
||||
|
||||
mod write {
|
||||
use bstr::{BStr, BString};
|
||||
|
||||
use crate::protocol::{context::serde::validate, Context};
|
||||
|
||||
impl Context {
|
||||
/// Write ourselves to `out` such that [`from_bytes()`][Self::from_bytes()] can decode it losslessly.
|
||||
pub fn write_to(&self, mut out: impl std::io::Write) -> std::io::Result<()> {
|
||||
use bstr::ByteSlice;
|
||||
fn write_key(out: &mut impl std::io::Write, key: &str, value: &BStr) -> std::io::Result<()> {
|
||||
out.write_all(key.as_bytes())?;
|
||||
out.write_all(b"=")?;
|
||||
out.write_all(value)?;
|
||||
out.write_all(b"\n")
|
||||
}
|
||||
let Context {
|
||||
protocol,
|
||||
host,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
oauth_refresh_token,
|
||||
password_expiry_utc,
|
||||
url,
|
||||
// We only decode quit and interpret it, but won't get to pass it on as it means to stop the
|
||||
// credential helper invocation chain.
|
||||
quit: _,
|
||||
} = self;
|
||||
for (key, value) in [("url", url), ("path", path)] {
|
||||
if let Some(value) = value {
|
||||
validate(key, value.as_slice().into()).map_err(std::io::Error::other)?;
|
||||
write_key(&mut out, key, value.as_ref()).ok();
|
||||
}
|
||||
}
|
||||
for (key, value) in [
|
||||
("protocol", protocol),
|
||||
("host", host),
|
||||
("username", username),
|
||||
("password", password),
|
||||
("oauth_refresh_token", oauth_refresh_token),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
validate(key, value.as_str().into()).map_err(std::io::Error::other)?;
|
||||
write_key(&mut out, key, value.as_bytes().as_bstr()).ok();
|
||||
}
|
||||
}
|
||||
if let Some(value) = password_expiry_utc {
|
||||
let key = "password_expiry_utc";
|
||||
let value = value.to_string();
|
||||
validate(key, value.as_str().into()).map_err(std::io::Error::other)?;
|
||||
write_key(&mut out, key, value.as_bytes().as_bstr()).ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like [`write_to()`][Self::write_to()], but writes infallibly into memory.
|
||||
pub fn to_bstring(&self) -> BString {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
self.write_to(&mut buf).expect("infallible");
|
||||
buf.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
pub mod decode {
|
||||
use bstr::{BString, ByteSlice};
|
||||
|
||||
use crate::protocol::{context, context::serde::validate, Context};
|
||||
|
||||
/// The error returned by [`from_bytes()`][Context::from_bytes()].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(missing_docs)]
|
||||
pub enum Error {
|
||||
#[error("Illformed UTF-8 in value of key {key:?}: {value:?}")]
|
||||
IllformedUtf8InValue { key: String, value: BString },
|
||||
#[error(transparent)]
|
||||
Encoding(#[from] context::Error),
|
||||
#[error("Invalid format in line {line:?}, expecting key=value")]
|
||||
Syntax { line: BString },
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Decode ourselves from `input` which is the format written by [`write_to()`][Self::write_to()].
|
||||
pub fn from_bytes(input: &[u8]) -> Result<Self, Error> {
|
||||
let mut ctx = Context::default();
|
||||
let Context {
|
||||
protocol,
|
||||
host,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
oauth_refresh_token,
|
||||
password_expiry_utc,
|
||||
url,
|
||||
quit,
|
||||
} = &mut ctx;
|
||||
for res in input.lines().take_while(|line| !line.is_empty()).map(|line| {
|
||||
let mut it = line.splitn(2, |b| *b == b'=');
|
||||
match (
|
||||
it.next().and_then(|k| k.to_str().ok()),
|
||||
it.next().map(ByteSlice::as_bstr),
|
||||
) {
|
||||
(Some(key), Some(value)) => validate(key, value)
|
||||
.map(|_| (key, value.to_owned()))
|
||||
.map_err(Into::into),
|
||||
_ => Err(Error::Syntax { line: line.into() }),
|
||||
}
|
||||
}) {
|
||||
let (key, value) = res?;
|
||||
match key {
|
||||
"protocol" | "host" | "username" | "password" | "oauth_refresh_token" => {
|
||||
if !value.is_utf8() {
|
||||
return Err(Error::IllformedUtf8InValue { key: key.into(), value });
|
||||
}
|
||||
let value = value.to_string();
|
||||
*match key {
|
||||
"protocol" => &mut *protocol,
|
||||
"host" => host,
|
||||
"username" => username,
|
||||
"password" => password,
|
||||
"oauth_refresh_token" => oauth_refresh_token,
|
||||
_ => unreachable!("checked field names in match above"),
|
||||
} = Some(value);
|
||||
}
|
||||
"password_expiry_utc" => {
|
||||
*password_expiry_utc = value.to_str().ok().and_then(|value| value.parse().ok());
|
||||
}
|
||||
"url" => *url = Some(value),
|
||||
"path" => *path = Some(value),
|
||||
"quit" => {
|
||||
*quit = gix_config_value::Boolean::try_from(value.as_ref()).ok().map(Into::into);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(key: &str, value: &BStr) -> Result<(), Error> {
|
||||
if key.contains('\0') || key.contains('\n') || value.contains(&0) || value.contains(&b'\n') {
|
||||
return Err(Error::Encoding {
|
||||
key: key.to_owned(),
|
||||
value: value.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
88
src-credentials/src/protocol/mod.rs
Normal file
88
src-credentials/src/protocol/mod.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use bstr::BString;
|
||||
|
||||
use crate::helper;
|
||||
|
||||
/// The outcome of the credentials top-level functions to obtain a complete identity.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// The identity provide by the helper.
|
||||
pub identity: gix_sec::identity::Account,
|
||||
/// A handle to the action to perform next in another call to [`helper::invoke()`][crate::helper::invoke()].
|
||||
pub next: helper::NextAction,
|
||||
}
|
||||
|
||||
/// The Result type used in credentials top-level functions to obtain a complete identity.
|
||||
pub type Result = std::result::Result<Option<Outcome>, Error>;
|
||||
|
||||
/// The error returned top-level credential functions.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(missing_docs)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] gix_url::parse::Error),
|
||||
#[error("Either 'url' field or both 'protocol' and 'host' fields must be provided")]
|
||||
UrlMissing,
|
||||
#[error(transparent)]
|
||||
ContextDecode(#[from] context::decode::Error),
|
||||
#[error(transparent)]
|
||||
InvokeHelper(#[from] helper::Error),
|
||||
#[error("Could not obtain identity for context: {}", { let mut buf = Vec::<u8>::new(); context.write_to(&mut buf).ok(); String::from_utf8_lossy(&buf).into_owned() })]
|
||||
IdentityMissing { context: Context },
|
||||
#[error("The handler asked to stop trying to obtain credentials")]
|
||||
Quit,
|
||||
#[error("Couldn't obtain {prompt}")]
|
||||
Prompt { prompt: String, source: gix_prompt::Error },
|
||||
}
|
||||
|
||||
/// Additional context to be passed to the credentials helper.
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq)]
|
||||
pub struct Context {
|
||||
/// The protocol over which the credential will be used (e.g., https).
|
||||
pub protocol: Option<String>,
|
||||
/// The remote hostname for a network credential. This includes the port number if one was specified (e.g., "example.com:8088").
|
||||
pub host: Option<String>,
|
||||
/// The path with which the credential will be used. E.g., for accessing a remote https repository, this will be the repository’s path on the server.
|
||||
/// It can also be a path on the file system.
|
||||
pub path: Option<BString>,
|
||||
/// The credential’s username, if we already have one (e.g., from a URL, the configuration, the user, or from a previously run helper).
|
||||
pub username: Option<String>,
|
||||
/// The credential’s password, if we are asking it to be stored.
|
||||
pub password: Option<String>,
|
||||
/// An OAuth refresh token that may accompany a password. It is to be treated confidentially, just like the password.
|
||||
pub oauth_refresh_token: Option<String>,
|
||||
/// The expiry date of OAuth tokens as seconds from Unix epoch.
|
||||
pub password_expiry_utc: Option<gix_date::SecondsSinceUnixEpoch>,
|
||||
/// When this special attribute is read by git credential, the value is parsed as a URL and treated as if its constituent
|
||||
/// parts were read (e.g., url=<https://example.com> would behave as if
|
||||
/// protocol=https and host=example.com had been provided). This can help callers avoid parsing URLs themselves.
|
||||
pub url: Option<BString>,
|
||||
/// If true, the caller should stop asking for credentials immediately without calling more credential helpers in the chain.
|
||||
pub quit: Option<bool>,
|
||||
}
|
||||
|
||||
/// Convert the outcome of a helper invocation to a helper result, assuring that the identity is complete in the process.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn helper_outcome_to_result(outcome: Option<helper::Outcome>, action: helper::Action) -> Result {
|
||||
match (action, outcome) {
|
||||
(helper::Action::Get(ctx), None) => Err(Error::IdentityMissing {
|
||||
context: ctx.redacted(),
|
||||
}),
|
||||
(helper::Action::Get(ctx), Some(mut outcome)) => match outcome.consume_identity() {
|
||||
Some(identity) => Ok(Some(Outcome {
|
||||
identity,
|
||||
next: outcome.next,
|
||||
})),
|
||||
None => Err(if outcome.quit {
|
||||
Error::Quit
|
||||
} else {
|
||||
Error::IdentityMissing {
|
||||
context: ctx.redacted(),
|
||||
}
|
||||
}),
|
||||
},
|
||||
(helper::Action::Store(_) | helper::Action::Erase(_), _ignore) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
pub mod context;
|
||||
5
src-credentials/tests/credentials.rs
Normal file
5
src-credentials/tests/credentials.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub use gix_testtools::Result;
|
||||
|
||||
mod helper;
|
||||
mod program;
|
||||
mod protocol;
|
||||
6
src-credentials/tests/fixtures/all-but-credentials.sh
Executable file
6
src-credentials/tests/fixtures/all-but-credentials.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo protocol=ftp
|
||||
echo host=example.com:8080
|
||||
echo path=/path/to/git/
|
||||
|
||||
6
src-credentials/tests/fixtures/custom-helper.sh
Executable file
6
src-credentials/tests/fixtures/custom-helper.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
test "$1" = get && \
|
||||
echo username=user-script && \
|
||||
echo password=pass-script
|
||||
7
src-credentials/tests/fixtures/expired.sh
Executable file
7
src-credentials/tests/fixtures/expired.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
test "$1" = get && \
|
||||
echo username=user-expired && \
|
||||
echo password=pass-expired && \
|
||||
echo password_expiry_utc=1
|
||||
3
src-credentials/tests/fixtures/fail.sh
Executable file
3
src-credentials/tests/fixtures/fail.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
exit 42
|
||||
6
src-credentials/tests/fixtures/last-pass.sh
Executable file
6
src-credentials/tests/fixtures/last-pass.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo username=user
|
||||
echo password=pass
|
||||
echo quit=1
|
||||
5
src-credentials/tests/fixtures/oauth-token.sh
Executable file
5
src-credentials/tests/fixtures/oauth-token.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
test "$1" = get && \
|
||||
echo oauth_refresh_token=oauth-token
|
||||
3
src-credentials/tests/fixtures/password.sh
Executable file
3
src-credentials/tests/fixtures/password.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo password=pass
|
||||
3
src-credentials/tests/fixtures/reflect.sh
Executable file
3
src-credentials/tests/fixtures/reflect.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
cat
|
||||
7
src-credentials/tests/fixtures/url.sh
Executable file
7
src-credentials/tests/fixtures/url.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo protocol=ftp
|
||||
echo host=github.com
|
||||
echo path=byron/gitoxide
|
||||
echo url=http://example.com:8080/path/to/git/
|
||||
|
||||
3
src-credentials/tests/fixtures/username.sh
Executable file
3
src-credentials/tests/fixtures/username.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo username=user
|
||||
183
src-credentials/tests/helper/cascade.rs
Normal file
183
src-credentials/tests/helper/cascade.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
mod invoke {
|
||||
use bstr::ByteSlice;
|
||||
use gix_credentials::{
|
||||
helper::{Action, Cascade},
|
||||
protocol,
|
||||
protocol::Context,
|
||||
Program,
|
||||
};
|
||||
use gix_sec::identity::Account;
|
||||
|
||||
#[test]
|
||||
fn credentials_are_filled_in_one_by_one_and_stop_when_complete() {
|
||||
let actual = invoke_cascade(["username", "password", "custom-helper"], action_get())
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
assert_eq!(actual.identity, identity("user", "pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usernames_in_urls_are_kept_if_the_helper_does_not_overwrite_it() {
|
||||
let actual = invoke_cascade(
|
||||
["password", "custom-helper"],
|
||||
Action::get_for_url("ssh://git@host.org/path"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
assert_eq!(actual.identity, identity("git", "pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_credentials_can_be_overwritten_by_complete_ones() {
|
||||
let actual = invoke_cascade(["username", "custom-helper"], action_get())
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
assert_eq!(actual.identity, identity("user-script", "pass-script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_helpers_for_filling_dont_interrupt() {
|
||||
let actual = invoke_cascade(["fail", "custom-helper"], action_get())
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
assert_eq!(actual.identity, identity("user-script", "pass-script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_split_in_get_to_support_scripts() {
|
||||
let actual = invoke_cascade(
|
||||
["reflect", "custom-helper"],
|
||||
Action::get_for_url("https://example.com:8080/path/git/"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
let ctx: Context = (&actual.next).try_into().unwrap();
|
||||
assert_eq!(ctx.protocol.as_deref().expect("protocol"), "https");
|
||||
assert_eq!(ctx.host.as_deref().expect("host"), "example.com:8080");
|
||||
assert_eq!(ctx.path.as_deref().expect("path").as_bstr(), "path/git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_split_in_get_but_can_skip_the_path_in_host_only_urls() {
|
||||
let actual = invoke_cascade(["reflect", "custom-helper"], Action::get_for_url("http://example.com"))
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
let ctx: Context = (&actual.next).try_into().unwrap();
|
||||
assert_eq!(ctx.protocol.as_deref().expect("protocol"), "http");
|
||||
assert_eq!(ctx.host.as_deref().expect("host"), "example.com");
|
||||
assert_eq!(ctx.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_can_set_any_context_value() {
|
||||
let actual = invoke_cascade(
|
||||
["all-but-credentials", "custom-helper"],
|
||||
Action::get_for_url("http://github.com"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
let ctx: Context = (&actual.next).try_into().unwrap();
|
||||
assert_eq!(ctx.protocol.as_deref().expect("protocol"), "ftp");
|
||||
assert_eq!(ctx.host.as_deref().expect("host"), "example.com:8080");
|
||||
assert_eq!(
|
||||
ctx.path.expect("set by helper"),
|
||||
"/path/to/git/",
|
||||
"values are passed verbatim even if they would otherwise look different"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_can_set_any_context_value_using_the_url_only() {
|
||||
let actual = invoke_cascade(["url", "custom-helper"], Action::get_for_url("http://github.com"))
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
let ctx: Context = (&actual.next).try_into().unwrap();
|
||||
assert_eq!(
|
||||
ctx.protocol.as_deref().expect("protocol"),
|
||||
"http",
|
||||
"url is processed last, it overwrites what came before"
|
||||
);
|
||||
assert_eq!(ctx.host.as_deref().expect("host"), "example.com:8080");
|
||||
assert_eq!(
|
||||
ctx.path.expect("set by helper"),
|
||||
"path/to/git",
|
||||
"the url is processed like any other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_can_quit_and_their_creds_are_taken_if_complete() {
|
||||
let actual = invoke_cascade(["last-pass", "custom-helper"], Action::get_for_url("http://github.com"))
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
assert_eq!(actual.identity, identity("user", "pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_credentials_are_not_returned() {
|
||||
let actual = invoke_cascade(
|
||||
["expired", "oauth-token", "custom-helper"],
|
||||
Action::get_for_url("http://github.com"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
|
||||
assert_eq!(
|
||||
actual.identity,
|
||||
Account {
|
||||
oauth_refresh_token: Some("oauth-token".into()),
|
||||
..identity("user-script", "pass-script")
|
||||
},
|
||||
"it ignored the expired password, which otherwise would have come first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bogus_password_overrides_any_helper_and_helper_overrides_username_in_url() {
|
||||
let actual = Cascade::default()
|
||||
.query_user_only(true)
|
||||
.extend(fixtures(["username", "password"]))
|
||||
.invoke(
|
||||
Action::get_for_url("ssh://git@host/repo"),
|
||||
gix_prompt::Options {
|
||||
mode: gix_prompt::Mode::Disable,
|
||||
askpass: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.expect("credentials");
|
||||
assert_eq!(actual.identity, identity("user", ""));
|
||||
}
|
||||
|
||||
fn action_get() -> Action {
|
||||
Action::get_for_url("does/not/matter")
|
||||
}
|
||||
|
||||
fn identity(user: &str, pass: &str) -> Account {
|
||||
Account {
|
||||
username: user.into(),
|
||||
password: pass.into(),
|
||||
oauth_refresh_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn invoke_cascade<'a>(names: impl IntoIterator<Item = &'a str>, action: Action) -> protocol::Result {
|
||||
Cascade::default().use_http_path(true).extend(fixtures(names)).invoke(
|
||||
action,
|
||||
gix_prompt::Options {
|
||||
mode: gix_prompt::Mode::Disable,
|
||||
askpass: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn fixtures<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<Program> {
|
||||
names.into_iter().map(crate::helper::script_helper).collect()
|
||||
}
|
||||
}
|
||||
123
src-credentials/tests/helper/context.rs
Normal file
123
src-credentials/tests/helper/context.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
#[test]
|
||||
fn encode_decode_roundtrip_works_only_for_serializing_fields() {
|
||||
for ctx in [
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("github.com".into()),
|
||||
path: Some("byron/gitoxide".into()),
|
||||
username: Some("user".into()),
|
||||
password: Some("pass".into()),
|
||||
url: Some("https://github.com/byron/gitoxide".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Context::default(),
|
||||
] {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
ctx.write_to(&mut buf).unwrap();
|
||||
let actual = Context::from_bytes(&buf).unwrap();
|
||||
assert_eq!(actual, ctx, "ctx should encode itself losslessly");
|
||||
}
|
||||
}
|
||||
|
||||
mod write_to {
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
#[test]
|
||||
fn quit_is_not_serialized_but_can_be_parsed() {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
Context {
|
||||
quit: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
.write_to(&mut buf)
|
||||
.unwrap();
|
||||
assert_eq!(Context::from_bytes(&buf).unwrap(), Context::default());
|
||||
assert_eq!(
|
||||
Context::from_bytes(b"quit=true\nurl=https://example.com").unwrap(),
|
||||
Context {
|
||||
quit: Some(true),
|
||||
url: Some("https://example.com".into()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_bytes_and_newlines_are_invalid() {
|
||||
for input in [&b"foo\0"[..], b"foo\n"] {
|
||||
let ctx = Context {
|
||||
path: Some(input.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut buf = Vec::<u8>::new();
|
||||
let err = ctx.write_to(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod from_bytes {
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
#[test]
|
||||
fn empty_newlines_cause_skipping_remaining_input() {
|
||||
let input = b"protocol=https
|
||||
host=example.com\n
|
||||
password=secr3t
|
||||
username=bob";
|
||||
assert_eq!(
|
||||
Context::from_bytes(input).unwrap(),
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("example.com".into()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_names_are_skipped() {
|
||||
let input = b"protocol=https
|
||||
unknown=value
|
||||
username=bob";
|
||||
assert_eq!(
|
||||
Context::from_bytes(input).unwrap(),
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
username: Some("bob".into()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_supports_git_config_boolean_values() {
|
||||
for true_value in ["1", "42", "-42", "true", "on", "yes"] {
|
||||
let input = format!("quit={true_value}");
|
||||
assert_eq!(
|
||||
Context::from_bytes(input.as_bytes()).unwrap().quit,
|
||||
Some(true),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
for false_value in ["0", "false", "off", "no"] {
|
||||
let input = format!("quit={false_value}");
|
||||
assert_eq!(
|
||||
Context::from_bytes(input.as_bytes()).unwrap().quit,
|
||||
Some(false),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_bytes_when_decoding() {
|
||||
let err = Context::from_bytes(b"url=https://foo\0").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
gix_credentials::protocol::context::decode::Error::Encoding(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
130
src-credentials/tests/helper/invoke.rs
Normal file
130
src-credentials/tests/helper/invoke.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use bstr::BString;
|
||||
use gix_credentials::{helper, protocol::Context};
|
||||
|
||||
use crate::helper::script_helper;
|
||||
|
||||
#[test]
|
||||
fn get() {
|
||||
let mut outcome = gix_credentials::helper::invoke(
|
||||
&mut script_helper("last-pass"),
|
||||
&helper::Action::get_for_url("https://github.com/byron/gitoxide"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("mock provides credentials");
|
||||
assert_eq!(
|
||||
outcome.consume_identity().expect("complete"),
|
||||
gix_sec::identity::Account {
|
||||
username: "user".into(),
|
||||
password: "pass".into(),
|
||||
oauth_refresh_token: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.next.store().payload().unwrap(),
|
||||
"username=user\npassword=pass\nquit=1\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_reject() {
|
||||
let ctx = Context {
|
||||
url: Some("https://github.com/byron/gitoxide".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let ctxbuf = || -> BString {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
ctx.write_to(&mut buf).expect("cannot fail");
|
||||
buf.into()
|
||||
};
|
||||
for action in [helper::Action::Store(ctxbuf()), helper::Action::Erase(ctxbuf())] {
|
||||
let outcome = gix_credentials::helper::invoke(&mut script_helper("last-pass"), &action).unwrap();
|
||||
assert!(
|
||||
outcome.is_none(),
|
||||
"store and erase have no outcome, they just shouldn't fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod program {
|
||||
use gix_credentials::{helper, program::Kind, Program};
|
||||
|
||||
use crate::helper::script_helper;
|
||||
|
||||
#[test]
|
||||
fn builtin() {
|
||||
assert!(
|
||||
matches!(
|
||||
gix_credentials::helper::invoke(
|
||||
&mut Program::from_kind(Kind::Builtin).suppress_stderr(),
|
||||
&helper::Action::get_for_url("/path/without/scheme/fails/with/error"),
|
||||
)
|
||||
.unwrap_err(),
|
||||
helper::Error::CredentialsHelperFailed { .. }
|
||||
),
|
||||
"this failure indicates we could launch the helper, even though it wasn't happy which is fine. It doesn't like the URL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script() {
|
||||
assert_eq!(
|
||||
gix_credentials::helper::invoke(
|
||||
&mut Program::from_custom_definition(
|
||||
"!f() { test \"$1\" = get && echo \"password=pass\" && echo \"username=user\"; }; f"
|
||||
),
|
||||
&helper::Action::get_for_url("/does/not/matter"),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("present")
|
||||
.consume_identity()
|
||||
.expect("complete"),
|
||||
gix_sec::identity::Account {
|
||||
username: "user".into(),
|
||||
password: "pass".into(),
|
||||
oauth_refresh_token: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)] // needs executable bits to work
|
||||
#[test]
|
||||
fn path_to_helper_script() -> crate::Result {
|
||||
assert_eq!(
|
||||
gix_credentials::helper::invoke(
|
||||
&mut Program::from_custom_definition(
|
||||
gix_path::into_bstr(gix_path::realpath(gix_testtools::fixture_path("custom-helper.sh"))?)
|
||||
.into_owned()
|
||||
),
|
||||
&helper::Action::get_for_url("/does/not/matter"),
|
||||
)?
|
||||
.expect("present")
|
||||
.consume_identity()
|
||||
.expect("complete"),
|
||||
gix_sec::identity::Account {
|
||||
username: "user-script".into(),
|
||||
password: "pass-script".into(),
|
||||
oauth_refresh_token: None
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_to_helper_as_script_to_workaround_executable_bits() -> crate::Result {
|
||||
assert_eq!(
|
||||
gix_credentials::helper::invoke(
|
||||
&mut script_helper("custom-helper"),
|
||||
&helper::Action::get_for_url("/does/not/matter")
|
||||
)?
|
||||
.expect("present")
|
||||
.consume_identity()
|
||||
.expect("complete"),
|
||||
gix_sec::identity::Account {
|
||||
username: "user-script".into(),
|
||||
password: "pass-script".into(),
|
||||
oauth_refresh_token: None
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
62
src-credentials/tests/helper/mod.rs
Normal file
62
src-credentials/tests/helper/mod.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
mod cascade;
|
||||
mod context;
|
||||
mod invoke;
|
||||
|
||||
mod invoke_outcome_to_helper_result {
|
||||
use gix_credentials::{helper, protocol, protocol::helper_outcome_to_result};
|
||||
|
||||
#[test]
|
||||
fn missing_username_or_password_causes_failure_with_get_action() {
|
||||
let action = helper::Action::get_for_url("does/not/matter");
|
||||
let err = helper_outcome_to_result(
|
||||
Some(helper::Outcome {
|
||||
username: None,
|
||||
password: None,
|
||||
oauth_refresh_token: None,
|
||||
quit: false,
|
||||
next: protocol::Context::default().into(),
|
||||
}),
|
||||
action,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, protocol::Error::IdentityMissing { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_message_in_context_causes_special_error_ignoring_missing_identity() {
|
||||
let action = helper::Action::get_for_url("does/not/matter");
|
||||
let err = helper_outcome_to_result(
|
||||
Some(helper::Outcome {
|
||||
username: None,
|
||||
password: None,
|
||||
oauth_refresh_token: None,
|
||||
quit: true,
|
||||
next: protocol::Context::default().into(),
|
||||
}),
|
||||
action,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, protocol::Error::Quit));
|
||||
}
|
||||
}
|
||||
|
||||
use bstr::{BString, ByteVec};
|
||||
use gix_credentials::Program;
|
||||
use gix_testtools::fixture_path;
|
||||
use std::{borrow::Cow, path::Path};
|
||||
|
||||
pub fn script_helper(name: &str) -> Program {
|
||||
fn to_arg<'a>(path: impl Into<Cow<'a, Path>>) -> BString {
|
||||
let utf8_encoded = gix_path::into_bstr(path);
|
||||
let slash_separated = gix_path::to_unix_separators_on_windows(utf8_encoded);
|
||||
gix_quote::single(slash_separated.as_ref())
|
||||
}
|
||||
|
||||
let shell = gix_path::env::shell();
|
||||
let fixture = gix_path::realpath(fixture_path(format!("{name}.sh"))).unwrap();
|
||||
|
||||
let mut script = to_arg(Path::new(shell));
|
||||
script.push_char(' ');
|
||||
script.push_str(to_arg(fixture));
|
||||
Program::from_kind(gix_credentials::program::Kind::ExternalShellScript(script))
|
||||
}
|
||||
120
src-credentials/tests/program/from_custom_definition.rs
Normal file
120
src-credentials/tests/program/from_custom_definition.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use gix_credentials::{helper, program::Kind, Program};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static GIT: std::sync::LazyLock<&'static str> = std::sync::LazyLock::new(|| {
|
||||
gix_path::env::exe_invocation()
|
||||
.to_str()
|
||||
.expect("some `from_custom_definition` tests must be run where 'git' path is valid Unicode")
|
||||
});
|
||||
|
||||
static SH: LazyLock<&'static str> = LazyLock::new(|| {
|
||||
gix_path::env::shell()
|
||||
.to_str()
|
||||
.expect("some `from_custom_definition` tests must be run where 'sh' path is valid Unicode")
|
||||
});
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let prog = Program::from_custom_definition("");
|
||||
let git = *GIT;
|
||||
assert!(matches!(&prog.kind, Kind::ExternalName { name_and_args } if name_and_args.is_empty()));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
format!(r#""{git}" "credential-" "store""#),
|
||||
"not useful, but allowed, would have to be caught elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_script_in_path() {
|
||||
let prog = Program::from_custom_definition("!exe");
|
||||
assert!(matches!(&prog.kind, Kind::ExternalShellScript(script) if script == "exe"));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
r#""exe" "store""#,
|
||||
"it didn't detect anything shell-scripty, and thus doesn't use a shell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_with_args() {
|
||||
let input = "name --arg --bar=\"a b\"";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
let git = *GIT;
|
||||
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
format!(r#""{git}" "credential-name" "--arg" "--bar=a b" "store""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_with_special_args() {
|
||||
let input = "name --arg --bar=~/folder/in/home";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
let sh = *SH;
|
||||
let git = *GIT;
|
||||
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
format!(r#""{sh}" "-c" "{git} credential-name --arg --bar=~/folder/in/home \"$@\"" "--" "store""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name() {
|
||||
let input = "name";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
let git = *GIT;
|
||||
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
format!(r#""{git}" "credential-name" "store""#),
|
||||
"we detect that this can run without shell, which is also more portable on windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_with_args_that_definitely_need_shell() {
|
||||
let input = "/abs/name --arg --bar=\"a b\"";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
|
||||
let sh = *SH;
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
if cfg!(windows) {
|
||||
r#""/abs/name" "--arg" "--bar=a b" "store""#.to_owned()
|
||||
} else {
|
||||
format!(r#""{sh}" "-c" "/abs/name --arg --bar=\"a b\" \"$@\"" "--" "store""#)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_without_args() {
|
||||
let input = "/abs/name";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
r#""/abs/name" "store""#,
|
||||
"no shell is used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_with_simple_args() {
|
||||
let input = "/abs/name a b";
|
||||
let prog = Program::from_custom_definition(input);
|
||||
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
|
||||
let sh = *SH;
|
||||
assert_eq!(
|
||||
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
|
||||
if cfg!(windows) {
|
||||
r#""/abs/name" "a" "b" "store""#.to_owned()
|
||||
} else {
|
||||
format!(r#""{sh}" "-c" "/abs/name a b \"$@\"" "--" "store""#)
|
||||
},
|
||||
"a shell is used as there are arguments, and it's generally more flexible, but on windows we split ourselves"
|
||||
);
|
||||
}
|
||||
94
src-credentials/tests/program/main.rs
Normal file
94
src-credentials/tests/program/main.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use gix_credentials::program::main;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Test error")]
|
||||
struct TestError;
|
||||
|
||||
#[test]
|
||||
fn protocol_and_host_without_url_is_valid() {
|
||||
let input = b"protocol=https\nhost=github.com\n";
|
||||
let mut output = Vec::new();
|
||||
|
||||
let mut called = false;
|
||||
let result = main(
|
||||
["get".into()],
|
||||
Cursor::new(input),
|
||||
&mut output,
|
||||
|_action, context| -> Result<Option<gix_credentials::protocol::Context>, TestError> {
|
||||
assert_eq!(context.protocol.as_deref(), Some("https"));
|
||||
assert_eq!(context.host.as_deref(), Some("github.com"));
|
||||
assert_eq!(context.url, None, "the URL isn't automatically populated");
|
||||
called = true;
|
||||
|
||||
Ok(None)
|
||||
},
|
||||
);
|
||||
|
||||
// This should fail because our mock helper returned None (no credentials found)
|
||||
// but it should NOT fail because of missing URL
|
||||
match result {
|
||||
Err(gix_credentials::program::main::Error::CredentialsMissing { .. }) => {
|
||||
assert!(
|
||||
called,
|
||||
"The helper gets called, but as nothing is provided in the function it ulimately fails"
|
||||
);
|
||||
}
|
||||
other => panic!("Expected CredentialsMissing error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_protocol_with_only_host_or_protocol_fails() {
|
||||
for input in ["host=github.com\n", "protocol=https\n"] {
|
||||
let mut output = Vec::new();
|
||||
|
||||
let mut called = false;
|
||||
let result = main(
|
||||
["get".into()],
|
||||
Cursor::new(input),
|
||||
&mut output,
|
||||
|_action, _context| -> Result<Option<gix_credentials::protocol::Context>, TestError> {
|
||||
called = true;
|
||||
Ok(None)
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(gix_credentials::program::main::Error::UrlMissing) => {
|
||||
assert!(!called, "the context is lacking, hence nothing gets called");
|
||||
}
|
||||
other => panic!("Expected UrlMissing error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_alone_is_valid() {
|
||||
let input = b"url=https://github.com\n";
|
||||
let mut output = Vec::new();
|
||||
|
||||
let mut called = false;
|
||||
let result = main(
|
||||
["get".into()],
|
||||
Cursor::new(input),
|
||||
&mut output,
|
||||
|_action, context| -> Result<Option<gix_credentials::protocol::Context>, TestError> {
|
||||
called = true;
|
||||
assert_eq!(context.url.unwrap(), "https://github.com");
|
||||
assert_eq!(context.host, None, "not auto-populated");
|
||||
assert_eq!(context.protocol, None, "not auto-populated");
|
||||
|
||||
Ok(None)
|
||||
},
|
||||
);
|
||||
|
||||
// This should fail because our mock helper returned None (no credentials found)
|
||||
// but it should NOT fail because of missing URL
|
||||
match result {
|
||||
Err(gix_credentials::program::main::Error::CredentialsMissing { .. }) => {
|
||||
assert!(called);
|
||||
}
|
||||
other => panic!("Expected CredentialsMissing error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
2
src-credentials/tests/program/mod.rs
Normal file
2
src-credentials/tests/program/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod from_custom_definition;
|
||||
mod main;
|
||||
226
src-credentials/tests/protocol/context.rs
Normal file
226
src-credentials/tests/protocol/context.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
mod destructure_url_in_place {
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
fn url_ctx(url: &str) -> Context {
|
||||
Context {
|
||||
url: Some(url.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_eq_parts(
|
||||
url: &str,
|
||||
proto: &str,
|
||||
user: impl Into<Option<&'static str>>,
|
||||
host: &str,
|
||||
path: impl Into<Option<&'static str>>,
|
||||
use_http_path: bool,
|
||||
) {
|
||||
let mut ctx = url_ctx(url);
|
||||
ctx.destructure_url_in_place(use_http_path).expect("splitting works");
|
||||
assert_eq!(ctx.protocol.expect("set proto"), proto);
|
||||
match user.into() {
|
||||
Some(expected) => assert_eq!(ctx.username.expect("set user"), expected),
|
||||
None => assert!(ctx.username.is_none()),
|
||||
}
|
||||
assert_eq!(ctx.host.expect("set host"), host);
|
||||
match path.into() {
|
||||
Some(expected) => assert_eq!(ctx.path.expect("set path"), expected),
|
||||
None => assert!(ctx.path.is_none()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_are_verbatim_with_non_http_url() {
|
||||
// path is always used for non-http
|
||||
assert_eq_parts("ssh://user@host:21/path", "ssh", "user", "host:21", "path", false);
|
||||
assert_eq_parts("ssh://host.org/path", "ssh", None, "host.org", "path", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_are_placed_in_context_too() -> crate::Result {
|
||||
let mut ctx = url_ctx("http://user:password@host/path");
|
||||
ctx.destructure_url_in_place(false)?;
|
||||
assert_eq!(ctx.password.as_deref(), Some("password"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_and_https_ignore_the_path_by_default() {
|
||||
assert_eq_parts(
|
||||
"http://user@example.com/path",
|
||||
"http",
|
||||
Some("user"),
|
||||
"example.com",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq_parts(
|
||||
"https://github.com/byron/gitoxide",
|
||||
"https",
|
||||
None,
|
||||
"github.com",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq_parts(
|
||||
"https://github.com/byron/gitoxide/",
|
||||
"https",
|
||||
None,
|
||||
"github.com",
|
||||
"byron/gitoxide",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_and_host_with_path_without_url_constructs_full_url() {
|
||||
let mut ctx = Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("github.com".into()),
|
||||
path: Some("org/repo".into()),
|
||||
username: Some("user".into()),
|
||||
password: Some("pass-to-be-ignored".into()),
|
||||
..Default::default()
|
||||
};
|
||||
ctx.destructure_url_in_place(false)
|
||||
.expect("should work with protocol, host and path");
|
||||
|
||||
assert_eq!(
|
||||
ctx.url.unwrap(),
|
||||
"https://user@github.com/org/repo",
|
||||
"URL should be constructed from all provided fields, except password"
|
||||
);
|
||||
// Original fields should be preserved
|
||||
assert_eq!(ctx.protocol.as_deref(), Some("https"));
|
||||
assert_eq!(ctx.host.as_deref(), Some("github.com"));
|
||||
assert_eq!(ctx.path.unwrap(), "org/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_protocol_or_host_without_url_fails() {
|
||||
let mut ctx_no_protocol = Context {
|
||||
host: Some("github.com".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
ctx_no_protocol.destructure_url_in_place(false).unwrap_err().to_string(),
|
||||
"Either 'url' field or both 'protocol' and 'host' fields must be provided"
|
||||
);
|
||||
|
||||
let mut ctx_no_host = Context {
|
||||
protocol: Some("https".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ctx_no_host.destructure_url_in_place(false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod to_prompt {
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
#[test]
|
||||
fn no_scheme_means_no_url() {
|
||||
assert_eq!(Context::default().to_prompt("Username"), "Username: ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_scheme_means_url_is_included() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("host".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_prompt("Password"),
|
||||
"Password for https://host: "
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod to_url {
|
||||
use gix_credentials::protocol::Context;
|
||||
|
||||
#[test]
|
||||
fn no_protocol_is_nothing() {
|
||||
assert_eq!(Context::default().to_url(), None);
|
||||
}
|
||||
#[test]
|
||||
fn protocol_alone_is_enough() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"https://"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn username_is_appended() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
username: Some("user".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"https://user@"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn host_is_appended() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
host: Some("host".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"https://host"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn path_is_appended_with_leading_slash_placed_as_needed() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("file".into()),
|
||||
path: Some("dir/git".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"file:///dir/git"
|
||||
);
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("file".into()),
|
||||
path: Some("/dir/git".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"file:///dir/git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_fields_with_port_but_password_is_never_shown() {
|
||||
assert_eq!(
|
||||
Context {
|
||||
protocol: Some("https".into()),
|
||||
username: Some("user".into()),
|
||||
password: Some("secret".into()),
|
||||
host: Some("example.com:8080".into()),
|
||||
path: Some("GitoxideLabs/gitoxide".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.to_url()
|
||||
.unwrap(),
|
||||
"https://user@example.com:8080/GitoxideLabs/gitoxide"
|
||||
);
|
||||
}
|
||||
}
|
||||
1
src-credentials/tests/protocol/mod.rs
Normal file
1
src-credentials/tests/protocol/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
mod context;
|
||||
Reference in New Issue
Block a user