Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ workspace = true

[features]
default = ["cli"]
cli = ["dep:gix", "dep:gix-traverse", "dep:ureq", "dep:clap", "dep:clap_complete", "dep:colored", "dep:hmac"]
cli = ["dep:gix", "dep:gix-traverse", "dep:ureq", "dep:clap", "dep:clap_complete", "dep:colored", "dep:hmac", "dep:mimalloc"]

[dependencies]
serde = { version = "1", features = ["derive"] }
Expand All @@ -57,6 +57,10 @@ ureq = { version = "3", features = ["json"], optional = true }
sha2 = "0.11.0"
hex = "0.4.3"
hmac = { version = "0.13", optional = true }
# mimalloc: faster allocator for the CLI hot paths (TagIndex::build,
# revwalk, regex captures). Typical 5-15% wall-time win on alloc-heavy
# workloads — see #507. CLI-only (no value for the wasm build).
mimalloc = { version = "0.1", default-features = false, optional = true }
# tempfile is used at runtime by the TS config loader to drop the
# generated wrapper script in a directory we own (security: writing into
# the user's config directory is a symlink TOCTOU). Tests also use it.
Expand Down
13 changes: 12 additions & 1 deletion deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@ exceptions = []
multiple-versions = "warn"
wildcards = "deny"
allow-wildcard-paths = true
deny = []
deny = [
# We migrated off libgit2 in #487. Reintroducing git2 / libgit2-sys
# silently re-adds ~3 MB to the release binary plus libgit2's CVE
# surface. Keep them banned.
{ crate = "git2", reason = "Use gix (gitoxide) — see #487." },
{ crate = "libgit2-sys", reason = "Pulled in only via git2 which is banned." },
# OpenSSL was vendored via the libgit2 chain. The gix migration
# removed it; if someone reintroduces it the binary jumps from
# ~5 MB to ~9 MB and we inherit OpenSSL's CVE cadence. Use rustls.
{ crate = "openssl-sys", reason = "Use rustls (via gix / ureq) instead." },
{ crate = "openssl-src", reason = "Vendoring OpenSSL bloats the binary." },
]
skip = []
skip-tree = []

Expand Down
3 changes: 3 additions & 0 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ mod repo;
mod retry;
mod shell;
mod tags;
mod validate;
#[allow(unused_imports)]
pub use validate::ensure_safe_refname_fragment;

pub use auth::get_remote_url;
#[allow(unused_imports)]
Expand Down
10 changes: 10 additions & 0 deletions src/git/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ pub fn verify_remote_branch(
branch: &str,
expected_oid: ObjectId,
) -> Result<()> {
super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?;
super::validate::ensure_safe_refname_fragment(branch, "branch name")?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow!("Bare repositories are not supported"))?;
Expand Down Expand Up @@ -151,13 +153,19 @@ fn resolve_push_source(repo: &Repository, branch: &str) -> String {
}

pub fn push_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?;
super::validate::ensure_safe_refname_fragment(branch, "branch name")?;
try_push_branch(repo, remote_name, branch)
}

pub fn push_tags(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result<()> {
if tags.is_empty() {
return Ok(());
}
super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?;
for tag in tags {
super::validate::ensure_safe_refname_fragment(tag, "tag name")?;
}
retry_transient("push tags", || try_push_tags_once(repo, remote_name, tags))
}

Expand Down Expand Up @@ -374,6 +382,8 @@ pub(super) fn fetch_and_rebase(repo: &Repository, remote_name: &str, branch: &st
}

pub fn reset_branch_to_remote(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?;
super::validate::ensure_safe_refname_fragment(branch, "branch name")?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow!("bare repos are not supported"))?;
Expand Down
8 changes: 5 additions & 3 deletions src/git/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,28 +586,30 @@ pub fn tag_exists(repo: &Repository, tag_name: &str) -> bool {
}

pub fn create_tag(repo: &Repository, tag_name: &str, message: &str) -> Result<()> {
super::validate::ensure_safe_refname_fragment(tag_name, "tag name")?;
if tag_exists(repo, tag_name) {
Err(anyhow::anyhow!("tag {tag_name} already exists"))
.error_code(error_code::GIT_TAG_EXISTS)?;
}
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?;
run_git(workdir, &["tag", "-a", tag_name, "-m", message])
run_git(workdir, &["tag", "-a", "-m", message, "--", tag_name])
.with_context(|| format!("git tag -a {tag_name} failed"))?;
Ok(())
}

pub fn create_or_move_tag(repo: &Repository, tag_name: &str, message: &str) -> Result<bool> {
super::validate::ensure_safe_refname_fragment(tag_name, "tag name")?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?;
let existed = tag_exists(repo, tag_name);
if existed {
run_git(workdir, &["tag", "-d", tag_name])
run_git(workdir, &["tag", "-d", "--", tag_name])
.with_context(|| format!("git tag -d {tag_name} failed"))?;
}
run_git(workdir, &["tag", "-a", tag_name, "-m", message])
run_git(workdir, &["tag", "-a", "-m", message, "--", tag_name])
.with_context(|| format!("git tag -a {tag_name} failed"))?;
Ok(existed)
}
Expand Down
99 changes: 99 additions & 0 deletions src/git/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use anyhow::{Result, anyhow};

/// Reject obviously-unsafe ref name fragments before passing them to
/// `git` subprocesses or interpolating them into refspecs.
///
/// This is **not** the full `git check-ref-format` algorithm — it only
/// catches the attack-relevant slice: leading `-` (flag-confusion on
/// older git), NUL / newline / control chars (header smuggling, broken
/// formatted error strings), and the empty string. Legitimate exotic
/// names (unicode emoji, slashes, dots) are intentionally allowed.
///
/// Call this before any `Command::new("git").arg(<refname>)` where the
/// `<refname>` could plausibly come from user-controlled input (config
/// file, env var, commit message, package name).
pub fn ensure_safe_refname_fragment(name: &str, context: &str) -> Result<()> {
if name.is_empty() {
return Err(anyhow!("{context}: ref name is empty"));
}
if name.starts_with('-') {
return Err(anyhow!(
"{context}: ref name '{name}' starts with '-', which some git versions \
may misinterpret as a command-line flag. Rename it."
));
}
for ch in name.chars() {
let bad = ch == '\0'
|| ch == '\n'
|| ch == '\r'
|| ch == '\x7f'
|| (ch.is_control() && !matches!(ch, '\t'));
if bad {
return Err(anyhow!(
"{context}: ref name '{name}' contains a disallowed control character (\
U+{:04X}). Rename it.",
ch as u32
));
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rejects_empty() {
assert!(ensure_safe_refname_fragment("", "tag").is_err());
}

#[test]
fn rejects_leading_dash() {
let err = ensure_safe_refname_fragment("--exec=ls", "tag")
.expect_err("should reject leading dash");
assert!(format!("{err:?}").contains("starts with '-'"));
}

#[test]
fn rejects_newline() {
assert!(ensure_safe_refname_fragment("foo\nbar", "tag").is_err());
assert!(ensure_safe_refname_fragment("foo\r", "tag").is_err());
}

#[test]
fn rejects_null_byte() {
assert!(ensure_safe_refname_fragment("foo\0bar", "tag").is_err());
}

#[test]
fn rejects_control_chars() {
assert!(ensure_safe_refname_fragment("foo\x01bar", "tag").is_err());
assert!(ensure_safe_refname_fragment("foo\x7fbar", "tag").is_err());
}

#[test]
fn allows_normal_names() {
for ok in &[
"v1.0.0",
"release/v1.0.0",
"api@v1.0.0",
"pkg/v1.2.3",
"feature-branch_2",
"1.x",
"v0.0.1-beta.42",
"我的分支",
] {
assert!(
ensure_safe_refname_fragment(ok, "test").is_ok(),
"should allow '{ok}'"
);
}
}

#[test]
fn allows_tab() {
// Tabs are technically allowed in refs (rare but not unsafe).
assert!(ensure_safe_refname_fragment("foo\tbar", "tag").is_ok());
}
}
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ mod telemetry;
mod validate;
mod versioning;

// Allocator swap for the CLI hot paths. The default system allocator
// (glibc malloc / Windows HeapAlloc) is well-known to be suboptimal on
// alloc-heavy short-lived CLIs; mimalloc consistently wins 5-15% on
// commit-walk / tag-scan / regex-capture workloads — see #507. Disabled
// in tests so they don't drag in the dep when not needed.
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

use clap::Parser;
use cli::Cli;

Expand Down
Loading
Loading