Harden local default-branch discovery variants

This commit is contained in:
Chris Bookholt
2026-06-29 15:43:32 -07:00
parent f9391a45e6
commit 566ab0ac77
3 changed files with 407 additions and 68 deletions
+227 -43
View File
@@ -29,6 +29,7 @@ use std::fs;
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::process::Command;
@@ -302,53 +303,233 @@ async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, Strin
(repo_path, branch)
}
async fn run_test_git(cwd: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.await
.unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}"))
}
async fn assert_test_git(cwd: &Path, args: &[&str]) {
let output = run_test_git(cwd, args).await;
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn test_default_branch_discovery_uses_verified_remote_head() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
assert_test_git(&repo_path, &["branch", "-M", "main"]).await;
assert_test_git(
&repo_path,
&["remote", "add", "upstream", "/nonexistent/upstream.git"],
)
.await;
assert_test_git(
&repo_path,
&["update-ref", "refs/remotes/upstream/release/v1", "HEAD"],
)
.await;
assert_test_git(
&repo_path,
&[
"symbolic-ref",
"refs/remotes/upstream/HEAD",
"refs/remotes/upstream/release/v1",
],
)
.await;
assert_eq!(
default_branch_name(&repo_path).await,
Some("release/v1".to_string())
);
}
#[tokio::test]
async fn test_default_branch_discovery_rejects_stale_and_cross_remote_heads() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
assert_test_git(&repo_path, &["branch", "-M", "main"]).await;
assert_test_git(
&repo_path,
&["remote", "add", "origin", "/nonexistent/origin.git"],
)
.await;
assert_test_git(
&repo_path,
&["remote", "add", "upstream", "/nonexistent/upstream.git"],
)
.await;
assert_test_git(
&repo_path,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/upstream/poisoned",
],
)
.await;
assert_test_git(
&repo_path,
&[
"symbolic-ref",
"refs/remotes/upstream/HEAD",
"refs/remotes/upstream/deleted",
],
)
.await;
assert_eq!(
default_branch_name(&repo_path).await,
Some("main".to_string())
);
}
#[tokio::test]
async fn test_default_branch_discovery_without_remotes_prefers_main_then_master() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
assert_test_git(&repo_path, &["branch", "-M", "master"]).await;
assert_eq!(
default_branch_name(&repo_path).await,
Some("master".to_string())
);
assert_test_git(&repo_path, &["branch", "main"]).await;
assert_eq!(
default_branch_name(&repo_path).await,
Some("main".to_string())
);
assert_test_git(&repo_path, &["branch", "-D", "main"]).await;
assert_test_git(&repo_path, &["branch", "-M", "feature"]).await;
assert_eq!(default_branch_name(&repo_path).await, None);
}
#[tokio::test]
async fn test_default_branch_discovery_from_linked_worktree() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let worktree_path = temp_dir.path().join("linked-worktree");
assert_test_git(&repo_path, &["branch", "-M", "main"]).await;
assert_test_git(
&repo_path,
&["remote", "add", "mirror", "/nonexistent/mirror.git"],
)
.await;
assert_test_git(
&repo_path,
&["update-ref", "refs/remotes/mirror/trunk", "HEAD"],
)
.await;
assert_test_git(
&repo_path,
&[
"symbolic-ref",
"refs/remotes/mirror/HEAD",
"refs/remotes/mirror/trunk",
],
)
.await;
assert_test_git(
&repo_path,
&[
"worktree",
"add",
"-b",
"feature",
worktree_path.to_string_lossy().as_ref(),
],
)
.await;
assert_eq!(
default_branch_name(&worktree_path).await,
Some("trunk".to_string())
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_default_branch_discovery_does_not_invoke_remote_transport() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let helper_path = temp_dir.path().join("ssh-helper.sh");
let marker_path = temp_dir.path().join("transport-ran");
fs::write(
&helper_path,
format!(
"#!/bin/sh\nprintf ran > \"{}\"\nexit 1\n",
marker_path.to_string_lossy()
),
)
.expect("write transport helper");
let mut permissions = fs::metadata(&helper_path)
.expect("read helper metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&helper_path, permissions).expect("mark helper executable");
let ssh_helper = temp_dir.path().join("ssh-helper.sh");
let ssh_marker = temp_dir.path().join("ssh-helper-ran");
let ext_helper = temp_dir.path().join("ext-helper.sh");
let ext_marker = temp_dir.path().join("ext-helper-ran");
let credential_helper = temp_dir.path().join("credential-helper.sh");
let credential_marker = temp_dir.path().join("credential-helper-ran");
let add_remote = Command::new("git")
.args(["remote", "add", "origin", "ssh://example.invalid/repo"])
.current_dir(&repo_path)
.output()
.await
.expect("add remote");
assert!(
add_remote.status.success(),
"add remote: {}",
String::from_utf8_lossy(&add_remote.stderr)
);
let configure_helper = Command::new("git")
.args([
for (helper, marker, output) in [
(&ssh_helper, &ssh_marker, ""),
(&ext_helper, &ext_marker, ""),
(
&credential_helper,
&credential_marker,
"printf 'username=test\\npassword=test\\n'\n",
),
] {
fs::write(
helper,
format!(
"#!/bin/sh\nprintf ran > \"{}\"\n{output}exit 1\n",
marker.to_string_lossy()
),
)
.expect("write transport helper");
let mut permissions = fs::metadata(helper)
.expect("read helper metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(helper, permissions).expect("mark helper executable");
}
assert_test_git(
&repo_path,
&["remote", "add", "origin", "ssh://example.invalid/repo"],
)
.await;
let ext_url = format!("ext::{}", ext_helper.to_string_lossy());
assert_test_git(&repo_path, &["remote", "add", "extension", &ext_url]).await;
assert_test_git(
&repo_path,
&[
"remote",
"add",
"credentialed",
"https://example.invalid/repo",
],
)
.await;
assert_test_git(
&repo_path,
&[
"config",
"core.sshCommand",
helper_path.to_string_lossy().as_ref(),
])
.current_dir(&repo_path)
.output()
.await
.expect("configure transport helper");
assert!(
configure_helper.status.success(),
"configure transport helper: {}",
String::from_utf8_lossy(&configure_helper.stderr)
);
ssh_helper.to_string_lossy().as_ref(),
],
)
.await;
assert_test_git(&repo_path, &["config", "protocol.ext.allow", "always"]).await;
assert_test_git(
&repo_path,
&[
"config",
"credential.helper",
credential_helper.to_string_lossy().as_ref(),
],
)
.await;
let branch_output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(&repo_path)
@@ -366,10 +547,13 @@ async fn test_default_branch_discovery_does_not_invoke_remote_transport() {
.to_string();
assert_eq!(default_branch_name(&repo_path).await, Some(branch));
assert!(
!marker_path.exists(),
"default branch discovery must stay on local refs"
);
for marker in [ssh_marker, ext_marker, credential_marker] {
assert!(
!marker.exists(),
"default branch discovery invoked {}",
marker.display()
);
}
}
#[tokio::test]
+28 -17
View File
@@ -487,23 +487,8 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
// Prefer the first remote (with origin prioritized)
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
for remote in remotes {
// Try symbolic-ref, which returns something like: refs/remotes/origin/main
if let Some(symref_output) = run_git_command_with_timeout(
&[
"symbolic-ref",
"--quiet",
&format!("refs/remotes/{remote}/HEAD"),
],
cwd,
)
.await
&& symref_output.status.success()
&& let Ok(sym) = String::from_utf8(symref_output.stdout)
{
let trimmed = sym.trim();
if let Some((_, name)) = trimmed.rsplit_once('/') {
return Some(name.to_string());
}
if let Some(branch) = get_remote_default_branch_from_symbolic_ref(cwd, &remote).await {
return Some(branch);
}
}
@@ -511,6 +496,32 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
get_default_branch_local(cwd).await
}
/// Resolves one remote's symbolic HEAD using only local refs.
///
/// The symbolic target must remain inside the selected remote's namespace and
/// resolve to an existing ref. This rejects stale or cross-remote symbolic refs
/// while preserving branch names that contain `/`.
async fn get_remote_default_branch_from_symbolic_ref(cwd: &Path, remote: &str) -> Option<String> {
let remote_head = format!("refs/remotes/{remote}/HEAD");
let symref_output =
run_git_command_with_timeout(&["symbolic-ref", "--quiet", &remote_head], cwd).await?;
if !symref_output.status.success() {
return None;
}
let target = String::from_utf8(symref_output.stdout).ok()?;
let target = target.trim();
let remote_ref_prefix = format!("refs/remotes/{remote}/");
let branch = target.strip_prefix(&remote_ref_prefix)?;
if branch.is_empty() {
return None;
}
let verify =
run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", target], cwd).await?;
verify.status.success().then(|| branch.to_string())
}
/// Determine the repository's default branch name, if available.
///
/// This inspects remote configuration first (including the symbolic `HEAD`
+152 -8
View File
@@ -526,7 +526,7 @@ mod tests {
deletions: 0,
}
);
assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"]));
runner.assert_finished();
}
#[tokio::test]
@@ -566,6 +566,140 @@ mod tests {
assert_eq!(stats.additions, 1);
assert!(!runner.saw(&["git", "remote", "show", "origin"]));
runner.assert_finished();
}
#[tokio::test]
async fn branch_diff_stats_skips_malformed_and_stale_remote_heads() {
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--git-dir"],
/*exit_code*/ 0,
".git\n",
),
response(
&["git", "remote"],
/*exit_code*/ 0,
"upstream\nbackup\norigin\n",
),
response(
&["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
/*exit_code*/ 0,
"refs/remotes/attacker/main\n",
),
response(
&[
"git",
"symbolic-ref",
"--quiet",
"refs/remotes/upstream/HEAD",
],
/*exit_code*/ 0,
"refs/remotes/upstream/deleted\n",
),
response(
&[
"git",
"rev-parse",
"--verify",
"--quiet",
"refs/remotes/upstream/deleted",
],
/*exit_code*/ 1,
"",
),
response(
&["git", "symbolic-ref", "--quiet", "refs/remotes/backup/HEAD"],
/*exit_code*/ 0,
"refs/remotes/backup/release/v1\n",
),
response(
&[
"git",
"rev-parse",
"--verify",
"--quiet",
"refs/remotes/backup/release/v1",
],
/*exit_code*/ 0,
"remote-release-sha\n",
),
response(
&[
"git",
"merge-base",
"HEAD",
"refs/remotes/backup/release/v1",
],
/*exit_code*/ 0,
"base-sha\n",
),
response(
&["git", "diff", "--numstat", "base-sha..HEAD"],
/*exit_code*/ 0,
"3\t2\tfile\n",
),
]);
let stats = branch_diff_stats_to_default_branch(&runner, Path::new("/repo"))
.await
.expect("branch diff stats");
assert_eq!(
stats,
GitBranchDiffStats {
additions: 3,
deletions: 2,
}
);
runner.assert_finished();
}
#[tokio::test]
async fn branch_diff_stats_without_remotes_uses_master_fallback() {
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--git-dir"],
/*exit_code*/ 0,
".git\n",
),
response(&["git", "remote"], /*exit_code*/ 0, ""),
response(
&["git", "rev-parse", "--verify", "--quiet", "refs/heads/main"],
/*exit_code*/ 1,
"",
),
response(
&[
"git",
"rev-parse",
"--verify",
"--quiet",
"refs/heads/master",
],
/*exit_code*/ 0,
"master-sha\n",
),
response(
&["git", "merge-base", "HEAD", "refs/heads/master"],
/*exit_code*/ 0,
"base-sha\n",
),
response(
&["git", "diff", "--numstat", "base-sha..HEAD"],
/*exit_code*/ 0,
"0\t0\tfile\n",
),
]);
assert_eq!(
branch_diff_stats_to_default_branch(&runner, Path::new("/repo")).await,
Some(GitBranchDiffStats {
additions: 0,
deletions: 0,
})
);
runner.assert_finished();
}
#[tokio::test]
@@ -671,9 +805,13 @@ mod tests {
);
}
fn command(argv: &[&str]) -> Vec<String> {
argv.iter().map(|arg| (*arg).to_string()).collect()
}
fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse {
FakeResponse {
argv: argv.iter().map(|arg| (*arg).to_string()).collect(),
argv: command(argv),
output: WorkspaceCommandOutput {
exit_code,
stdout: stdout.to_string(),
@@ -701,13 +839,20 @@ mod tests {
}
fn saw(&self, argv: &[&str]) -> bool {
let argv: Vec<String> = argv.iter().map(|arg| (*arg).to_string()).collect();
let argv = command(argv);
self.seen
.lock()
.expect("seen lock")
.iter()
.any(|seen| seen == &argv)
}
fn assert_finished(&self) {
assert!(
self.responses.lock().expect("responses lock").is_empty(),
"not all expected commands ran"
);
}
}
impl WorkspaceCommandExecutor for FakeRunner {
@@ -727,11 +872,10 @@ mod tests {
.push(command.argv.clone());
Box::pin(async move {
let mut responses = self.responses.lock().expect("responses lock");
let index = responses
.iter()
.position(|response| response.argv == command.argv)
.unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv));
let response = responses.remove(index).expect("fake response");
let response = responses.pop_front().unwrap_or_else(|| {
panic!("missing fake response for command {:?}", command.argv)
});
assert_eq!(response.argv, command.argv, "unexpected command sequence");
Ok(response.output)
})
}