create src

This commit is contained in:
awfixer
2026-03-11 02:04:19 -07:00
commit 52f7a22bf2
2595 changed files with 402870 additions and 0 deletions

1727
src-credentials/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View 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",
]

View File

@@ -0,0 +1 @@
../LICENSE-APACHE

1
src-credentials/LICENSE-MIT Symbolic link
View File

@@ -0,0 +1 @@
../LICENSE-MIT

View 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),
}
},
)
}

View 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()))
},
)
}

View 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(())
}

View 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,
)
}
}

View 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)),
}
}

View 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;

View 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,
)
}

View 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(())
}
}

View 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;

View 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;

View 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(())
}

View 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 repositorys path on the server.
/// It can also be a path on the file system.
pub path: Option<BString>,
/// The credentials 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 credentials 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;

View File

@@ -0,0 +1,5 @@
pub use gix_testtools::Result;
mod helper;
mod program;
mod protocol;

View File

@@ -0,0 +1,6 @@
#!/bin/sh
echo protocol=ftp
echo host=example.com:8080
echo path=/path/to/git/

View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
test "$1" = get && \
echo username=user-script && \
echo password=pass-script

View 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

View File

@@ -0,0 +1,3 @@
#!/bin/sh
exit 42

View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
echo username=user
echo password=pass
echo quit=1

View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
test "$1" = get && \
echo oauth_refresh_token=oauth-token

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo password=pass

View File

@@ -0,0 +1,3 @@
#!/bin/sh
cat

View 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/

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo username=user

View 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()
}
}

View 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(_)
));
}
}

View 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(())
}
}

View 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))
}

View 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"
);
}

View 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:?}"),
}
}

View File

@@ -0,0 +1,2 @@
mod from_custom_definition;
mod main;

View 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"
);
}
}

View File

@@ -0,0 +1 @@
mod context;