gitbaby is an asynchronous Rust library wrapping git operations, built on gix + tokio + async-trait. Module layout: - archive, blame, blob, branch, cleanup, commit, compare, config, conflict, diff, merge, refs, remote, setup, share, submodule, tags, tree (feature modules) - command: command scheduler (cmd/env/pipe/context/error) - repo: RepositoryFacade trait (consumers implement, exposing a gix::Repository) Features: - async API on tokio 1.53 - order-preserving Env (Vec<(String, String)>) - hand-written BabyError / CmdError, not using thiserror derive - integration tests covering cmd_run / context / env / pipe (env keys prefixed for isolation) CI: none, standard cargo build / test / clippy / fmt only
206 lines
5.2 KiB
Rust
206 lines
5.2 KiB
Rust
use std::fmt;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Debug)]
|
|
pub enum CmdError {
|
|
Spawn {
|
|
prog: String,
|
|
source: std::io::Error,
|
|
},
|
|
Io {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
NonZeroExit {
|
|
prog: String,
|
|
args: Vec<String>,
|
|
code: i32,
|
|
stderr: String,
|
|
},
|
|
Timeout {
|
|
prog: String,
|
|
timeout_ms: u64,
|
|
},
|
|
InvalidArgs(String),
|
|
Cancelled,
|
|
Pipe(String),
|
|
PayloadTooLarge {
|
|
cap_bytes: u64,
|
|
actual_bytes: u64,
|
|
},
|
|
Custom(String),
|
|
}
|
|
|
|
impl fmt::Display for CmdError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Spawn { prog, source } => {
|
|
write!(f, "failed to spawn `{}`: {}", prog, source)
|
|
}
|
|
Self::Io { path, source } => {
|
|
write!(f, "io error at `{}`: {}", path.display(), source)
|
|
}
|
|
Self::NonZeroExit {
|
|
prog,
|
|
args,
|
|
code,
|
|
stderr,
|
|
} => {
|
|
let argv_summary = redact_argv(prog, args);
|
|
write!(
|
|
f,
|
|
"`{} {}` exited with code {}: {}",
|
|
prog,
|
|
argv_summary,
|
|
code,
|
|
stderr.trim()
|
|
)
|
|
}
|
|
Self::Timeout { prog, timeout_ms } => {
|
|
write!(f, "`{}` timed out after {}ms", prog, timeout_ms)
|
|
}
|
|
Self::InvalidArgs(msg) => write!(f, "invalid args: {}", msg),
|
|
Self::Cancelled => write!(f, "command was cancelled"),
|
|
Self::Pipe(msg) => write!(f, "pipe error: {}", msg),
|
|
Self::PayloadTooLarge {
|
|
cap_bytes,
|
|
actual_bytes,
|
|
} => write!(
|
|
f,
|
|
"output exceeds cap: {} bytes > {} bytes cap",
|
|
actual_bytes, cap_bytes
|
|
),
|
|
Self::Custom(msg) => write!(f, "{}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CmdError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Spawn { source, .. } | Self::Io { source, .. } => Some(source),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for CmdError {
|
|
fn from(source: std::io::Error) -> Self {
|
|
Self::Io {
|
|
path: PathBuf::new(),
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type CmdResult<T> = std::result::Result<T, CmdError>;
|
|
|
|
const REDACTED: &str = "***REDACTED***";
|
|
|
|
fn redact_argv(prog: &str, args: &[String]) -> String {
|
|
let mut out = String::new();
|
|
out.push_str(prog);
|
|
let mut redact_next = false;
|
|
for (idx, arg) in args.iter().enumerate() {
|
|
out.push(' ');
|
|
if redact_next {
|
|
out.push_str(REDACTED);
|
|
redact_next = false;
|
|
continue;
|
|
}
|
|
if SENSITIVE_FLAGS
|
|
.iter()
|
|
.any(|flag| arg == *flag || arg.starts_with(flag))
|
|
{
|
|
let label = if arg.contains('=') {
|
|
arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
|
|
} else {
|
|
arg.as_str()
|
|
};
|
|
out.push_str(label);
|
|
out.push('=');
|
|
out.push_str(REDACTED);
|
|
continue;
|
|
}
|
|
if URL_VALUE_FLAGS
|
|
.iter()
|
|
.any(|flag| arg == *flag || arg.starts_with(flag))
|
|
{
|
|
let label = if arg.contains('=') {
|
|
arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
|
|
} else {
|
|
arg.as_str()
|
|
};
|
|
out.push_str(label);
|
|
if idx + 1 < args.len() && looks_like_url(&args[idx + 1]) {
|
|
out.push(' ');
|
|
out.push_str(REDACTED);
|
|
let _ = arg;
|
|
continue;
|
|
}
|
|
out.push_str(" <value>");
|
|
continue;
|
|
}
|
|
if looks_like_url(arg) {
|
|
out.push_str(REDACTED);
|
|
continue;
|
|
}
|
|
if idx == args.len() - 1 && redact_next_marker(prog, arg) {
|
|
redact_next = true;
|
|
}
|
|
out.push_str(arg);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn looks_like_url(s: &str) -> bool {
|
|
if s.starts_with("http://")
|
|
|| s.starts_with("https://")
|
|
|| s.starts_with("ssh://")
|
|
|| s.starts_with("git://")
|
|
|| s.starts_with("file://")
|
|
{
|
|
true
|
|
} else {
|
|
s.contains("://") && s.contains('@')
|
|
}
|
|
}
|
|
|
|
fn redact_next_marker(prog: &str, arg: &str) -> bool {
|
|
if prog != "git" {
|
|
return false;
|
|
}
|
|
matches!(
|
|
arg,
|
|
"credential.helper"
|
|
| "credential.username"
|
|
| "credential.password"
|
|
| "http.extraheader"
|
|
| "core.sshcommand"
|
|
| "core.gitproxy"
|
|
| "url.<base>.insteadof"
|
|
| "include.path"
|
|
)
|
|
}
|
|
|
|
static SENSITIVE_FLAGS: &[&str] = &[
|
|
"--upload-pack=",
|
|
"--receive-pack=",
|
|
"-c",
|
|
"--config=",
|
|
"--config-env=",
|
|
"-S",
|
|
"--gpg-sign=",
|
|
"--key-id=",
|
|
"--ssh-command=",
|
|
"--askpass=",
|
|
];
|
|
|
|
static URL_VALUE_FLAGS: &[&str] = &[
|
|
"remote.add",
|
|
"remote set-url",
|
|
"remote prune",
|
|
"--exec=",
|
|
"--uploadarchive=",
|
|
];
|