Skip to content

Commit 259ef67

Browse files
committed
fix(node): reject revision shorthands and split ref-validation errors
git check-ref-format accepts @ and pseudoref names that git later treats as symbolic revisions, so merge could succeed without merging the source branch. Reject those explicitly at the shared validator and add sink/boundary tests. Spawn failures from check-ref-format now surface as git_error (500) instead of bad_request (400) at create_pr/create_repo boundaries.
1 parent 799b73a commit 259ef67

5 files changed

Lines changed: 243 additions & 15 deletions

File tree

‎crates/gitlawb-node/src/api/mod.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()
9898
/// caller. Its unit tests live beside the definition in `git/store.rs`.
9999
pub(crate) use crate::git::store::validate_git_ref;
100100

101+
/// Map ref-validation failures to the correct HTTP surface: malformed caller
102+
/// input stays 400; an inability to run git stays on the git/server-error path.
103+
pub(crate) fn map_git_ref_validation_error(
104+
err: crate::git::store::GitRefValidationError,
105+
) -> AppError {
106+
match err {
107+
crate::git::store::GitRefValidationError::Invalid(msg) => AppError::BadRequest(msg),
108+
crate::git::store::GitRefValidationError::GitUnavailable(msg) => AppError::Git(msg),
109+
}
110+
}
111+
101112
#[cfg(test)]
102113
mod did_tests {
103114
use super::did_matches;

‎crates/gitlawb-node/src/api/pulls.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ pub async fn create_pr(
5757
// default_branch, but validating here as well means a PR can never feed the
5858
// git sink an unchecked ref even if a default was poisoned by an older row
5959
// or a future path that skips create_repo's gate.
60-
crate::api::validate_git_ref(&req.source_branch).map_err(AppError::BadRequest)?;
61-
crate::api::validate_git_ref(&target_branch).map_err(AppError::BadRequest)?;
60+
crate::api::validate_git_ref(&req.source_branch)
61+
.map_err(crate::api::map_git_ref_validation_error)?;
62+
crate::api::validate_git_ref(&target_branch)
63+
.map_err(crate::api::map_git_ref_validation_error)?;
6264
let number = state.db.next_pr_number(&record.id).await?;
6365
let now = Utc::now().to_rfc3339();
6466

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ pub async fn create_repo(
245245
// default_branch is caller-supplied and becomes a PR's target_branch when the
246246
// PR omits one, which is interpolated into a git revision argument. Validate
247247
// it as a ref so it cannot begin with '-' and inject a git option downstream.
248-
crate::api::validate_git_ref(&req.default_branch).map_err(AppError::BadRequest)?;
248+
crate::api::validate_git_ref(&req.default_branch)
249+
.map_err(crate::api::map_git_ref_validation_error)?;
249250

250251
// Owner is the authenticated agent's DID
251252
let owner_did = auth.0;

‎crates/gitlawb-node/src/git/store.rs‎

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,46 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
746746
Ok(Some((obj_type, content)))
747747
}
748748

749+
/// Outcome of validating a stored branch-field value.
750+
#[derive(Debug, Clone, PartialEq, Eq)]
751+
pub enum GitRefValidationError {
752+
/// Caller supplied a name that is not a valid local branch name.
753+
Invalid(String),
754+
/// Git could not be executed to validate the name.
755+
GitUnavailable(String),
756+
}
757+
758+
impl std::fmt::Display for GitRefValidationError {
759+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760+
match self {
761+
Self::Invalid(msg) | Self::GitUnavailable(msg) => f.write_str(msg),
762+
}
763+
}
764+
}
765+
766+
/// Reject revision shorthands and pseudoref names that `git check-ref-format
767+
/// --branch` accepts but that git later interprets as symbolic revisions rather
768+
/// than literal local branch names.
769+
fn reject_revision_shorthand(name: &str) -> Option<&'static str> {
770+
if name == "@" {
771+
return Some("branch ref must not be a revision shorthand");
772+
}
773+
if name.starts_with("@{") {
774+
return Some("branch ref must not be a reflog revision expression");
775+
}
776+
const PSEUDOREFS: &[&str] = &[
777+
"FETCH_HEAD",
778+
"ORIG_HEAD",
779+
"MERGE_HEAD",
780+
"CHERRY_PICK_HEAD",
781+
"RERERE_MERGE_HEAD",
782+
];
783+
if PSEUDOREFS.contains(&name) {
784+
return Some("branch ref must not be a git pseudoref name");
785+
}
786+
None
787+
}
788+
749789
/// Validate a git *branch name* before it is stored or interpolated into a git
750790
/// argv element. Delegates to `git check-ref-format --branch`, then rejects
751791
/// fully qualified ref paths (`refs/heads/...`, `refs/tags/...`) that git would
@@ -754,6 +794,9 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
754794
///
755795
/// Symbolic revision names (`HEAD`), option-shaped names (leading `-`), trailing
756796
/// dots, and other invalid forms are rejected by check-ref-format itself.
797+
/// Revision shorthands that pass the grammar check (`@`, `@{-1}`, pseudorefs)
798+
/// are rejected explicitly because git treats them as symbolic revisions at the
799+
/// sink, not as literal branch names.
757800
///
758801
/// A `--` delimiter is not the fix at the sink: these arguments are revisions,
759802
/// and `--` there reinterprets them as pathspecs.
@@ -762,34 +805,59 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
762805
/// call it too via the `crate::api` re-export to fail fast with a 400, but the
763806
/// guard here is what makes the property hold for every caller and every row,
764807
/// including legacy rows and any writer that skipped the boundary check.
765-
pub fn validate_git_ref(name: &str) -> std::result::Result<(), String> {
808+
pub fn validate_git_ref(name: &str) -> std::result::Result<(), GitRefValidationError> {
809+
validate_git_ref_with_git("git", name)
810+
}
811+
812+
fn validate_git_ref_with_git(
813+
git_bin: &str,
814+
name: &str,
815+
) -> std::result::Result<(), GitRefValidationError> {
766816
if name.is_empty() {
767-
return Err("branch ref must not be empty".into());
817+
return Err(GitRefValidationError::Invalid(
818+
"branch ref must not be empty".into(),
819+
));
768820
}
769821
if name.starts_with("refs/") {
770-
return Err("branch ref must be a local branch name, not a fully qualified ref".into());
822+
return Err(GitRefValidationError::Invalid(
823+
"branch ref must be a local branch name, not a fully qualified ref".into(),
824+
));
825+
}
826+
if let Some(reason) = reject_revision_shorthand(name) {
827+
return Err(GitRefValidationError::Invalid(reason.into()));
771828
}
772829
// allow-unbounded-git: stateless check-ref-format on a user-supplied name; no repo acquire or concurrency permit
773-
let output = Command::new("git")
830+
let output = Command::new(git_bin)
774831
.args(["check-ref-format", "--branch", name])
775832
.output()
776-
.map_err(|e| format!("failed to run git check-ref-format: {e}"))?;
833+
.map_err(|e| {
834+
GitRefValidationError::GitUnavailable(format!(
835+
"failed to run git check-ref-format: {e}"
836+
))
837+
})?;
777838

778839
if output.status.success() {
779840
Ok(())
780841
} else {
781842
let stderr = String::from_utf8_lossy(&output.stderr);
782-
Err(stderr.trim().to_string())
843+
Err(GitRefValidationError::Invalid(stderr.trim().to_string()))
783844
}
784845
}
785846

786847
/// Reject both refs at the sink, so an option-shaped ref can never reach a git
787848
/// argv element regardless of how it was stored.
849+
fn map_ref_validation_to_anyhow(which: &str, err: GitRefValidationError) -> anyhow::Error {
850+
match err {
851+
GitRefValidationError::Invalid(msg) => {
852+
anyhow::anyhow!("invalid {which} branch ref: {msg}")
853+
}
854+
GitRefValidationError::GitUnavailable(msg) => anyhow::anyhow!("{msg}"),
855+
}
856+
}
857+
788858
fn guard_refs(target_branch: &str, source_branch: &str) -> Result<()> {
789-
validate_git_ref(target_branch)
790-
.map_err(|e| anyhow::anyhow!("invalid target branch ref: {e}"))?;
791-
validate_git_ref(source_branch)
792-
.map_err(|e| anyhow::anyhow!("invalid source branch ref: {e}"))?;
859+
validate_git_ref(target_branch).map_err(|e| map_ref_validation_to_anyhow("target", e))?;
860+
validate_git_ref(source_branch).map_err(|e| map_ref_validation_to_anyhow("source", e))?;
793861
Ok(())
794862
}
795863

@@ -928,7 +996,7 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
928996

929997
#[cfg(test)]
930998
mod tests {
931-
use super::validate_git_ref;
999+
use super::{validate_git_ref, validate_git_ref_with_git, GitRefValidationError};
9321000

9331001
#[test]
9341002
fn validate_git_ref_accepts_normal_branch_names() {
@@ -938,6 +1006,8 @@ mod tests {
9381006
"release-1.2",
9391007
"v1.0.0",
9401008
"user/fix-bug",
1009+
"feature@",
1010+
"user@host",
9411011
] {
9421012
assert!(
9431013
validate_git_ref(good).is_ok(),
@@ -951,6 +1021,10 @@ mod tests {
9511021
for bad in [
9521022
"",
9531023
"HEAD",
1024+
"@",
1025+
"@{-1}",
1026+
"FETCH_HEAD",
1027+
"MERGE_HEAD",
9541028
"feature.",
9551029
"feature/x.",
9561030
"refs/heads/main",
@@ -967,10 +1041,24 @@ mod tests {
9671041
"trailing/",
9681042
"a//b",
9691043
] {
970-
assert!(validate_git_ref(bad).is_err(), "{bad:?} should be rejected");
1044+
let err = validate_git_ref(bad).expect_err("{bad:?} should be rejected");
1045+
assert!(
1046+
matches!(err, GitRefValidationError::Invalid(_)),
1047+
"{bad:?} should be an invalid-name rejection, got {err:?}"
1048+
);
9711049
}
9721050
}
9731051

1052+
#[test]
1053+
fn validate_git_ref_spawn_failure_is_git_unavailable_not_invalid() {
1054+
let err = validate_git_ref_with_git("/nonexistent/gitlawb-git", "main")
1055+
.expect_err("missing git binary should fail");
1056+
assert!(
1057+
matches!(err, GitRefValidationError::GitUnavailable(_)),
1058+
"spawn failure must not be classified as invalid input, got {err:?}"
1059+
);
1060+
}
1061+
9741062
use super::branch_diff_names;
9751063
use std::path::Path;
9761064
use std::process::Command;

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,10 +1040,15 @@ mod tests {
10401040

10411041
for (field, bad) in [
10421042
("source_branch", "HEAD"),
1043+
("source_branch", "@"),
1044+
("source_branch", "@{-1}"),
1045+
("source_branch", "FETCH_HEAD"),
10431046
("source_branch", "feature."),
10441047
("source_branch", "feature/x."),
10451048
("source_branch", "refs/tags/v1"),
10461049
("target_branch", "HEAD"),
1050+
("target_branch", "@"),
1051+
("target_branch", "@{-1}"),
10471052
("target_branch", "feature."),
10481053
("target_branch", "feature/x."),
10491054
("target_branch", "refs/heads/main"),
@@ -1087,6 +1092,9 @@ mod tests {
10871092

10881093
for (name_suffix, bad) in [
10891094
("head", "HEAD"),
1095+
("at-shorthand", "@"),
1096+
("reflog-shorthand", "@{-1}"),
1097+
("fetch-head", "FETCH_HEAD"),
10901098
("trail-dot", "feature."),
10911099
("trail-dot-comp", "feature/x."),
10921100
("tag-ref", "refs/tags/v1"),
@@ -1240,6 +1248,124 @@ mod tests {
12401248
);
12411249
}
12421250

1251+
/// SINK GUARD: a legacy row with source_branch=@ must be rejected before
1252+
/// merge_branch runs git. Without the sink guard, git merge @ is a no-op on
1253+
/// the checked-out target and the PR is recorded as merged without merging
1254+
/// the submitted source branch.
1255+
#[sqlx::test]
1256+
async fn legacy_pr_row_with_at_shorthand_source_is_rejected_at_merge_sink(pool: PgPool) {
1257+
use gitlawb_core::identity::Keypair;
1258+
use std::process::Command;
1259+
struct DirGuard(std::path::PathBuf);
1260+
impl Drop for DirGuard {
1261+
fn drop(&mut self) {
1262+
let _ = std::fs::remove_dir_all(&self.0);
1263+
}
1264+
}
1265+
1266+
let kp = Keypair::generate();
1267+
let owner_did = kp.did().to_string();
1268+
let short = owner_did.split(':').next_back().unwrap().to_string();
1269+
let slug = owner_did.replace([':', '/'], "_");
1270+
let state = test_state(pool).await;
1271+
let run = |args: &[&str], cwd: &std::path::Path| {
1272+
let out = Command::new("git")
1273+
.args(args)
1274+
.current_dir(cwd)
1275+
.output()
1276+
.expect("git");
1277+
assert!(
1278+
out.status.success(),
1279+
"git {args:?}: {}",
1280+
String::from_utf8_lossy(&out.stderr)
1281+
);
1282+
};
1283+
let src = std::env::temp_dir().join(format!("gl-at-src-{short}"));
1284+
let _ = std::fs::remove_dir_all(&src);
1285+
std::fs::create_dir_all(&src).unwrap();
1286+
let _sg = DirGuard(src.clone());
1287+
run(&["init", "-q", "-b", "main"], &src);
1288+
run(&["config", "user.email", "t@t"], &src);
1289+
run(&["config", "user.name", "t"], &src);
1290+
std::fs::write(src.join("f.txt"), b"hi").unwrap();
1291+
run(&["add", "f.txt"], &src);
1292+
run(&["commit", "-q", "-m", "seed"], &src);
1293+
run(&["branch", "feature"], &src);
1294+
std::fs::write(src.join("f.txt"), b"changed").unwrap();
1295+
run(&["add", "f.txt"], &src);
1296+
run(&["commit", "-q", "-m", "on feature"], &src);
1297+
let bare = std::path::PathBuf::from("/tmp")
1298+
.join(&slug)
1299+
.join("at-probe.git");
1300+
let _ = std::fs::remove_dir_all(&bare);
1301+
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
1302+
let _bg = DirGuard(bare.clone());
1303+
run(
1304+
&[
1305+
"clone",
1306+
"--bare",
1307+
"-q",
1308+
src.to_str().unwrap(),
1309+
bare.to_str().unwrap(),
1310+
],
1311+
&std::env::temp_dir(),
1312+
);
1313+
1314+
let mut repo = seed_repo(&owner_did, "at-probe");
1315+
repo.is_public = true;
1316+
state.db.create_repo(&repo).await.expect("seed repo");
1317+
1318+
let pr = crate::db::PullRequest {
1319+
id: uuid::Uuid::new_v4().to_string(),
1320+
repo_id: repo.id.clone(),
1321+
number: 1,
1322+
title: "x".into(),
1323+
body: None,
1324+
author_did: owner_did.clone(),
1325+
source_branch: "@".into(),
1326+
target_branch: "main".into(),
1327+
status: "open".into(),
1328+
merged_by_did: None,
1329+
merged_at: None,
1330+
created_at: chrono::Utc::now().to_rfc3339(),
1331+
updated_at: chrono::Utc::now().to_rfc3339(),
1332+
};
1333+
state.db.create_pr(&pr).await.expect("insert legacy row");
1334+
1335+
let router = Router::new()
1336+
.route(
1337+
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
1338+
axum::routing::post(crate::api::pulls::merge_pr),
1339+
)
1340+
.with_state(state.clone());
1341+
let uri = format!("/api/v1/repos/{owner_did}/at-probe/pulls/1/merge");
1342+
let resp = router
1343+
.oneshot(signed_request_as(
1344+
&owner_did,
1345+
Method::POST,
1346+
&uri,
1347+
Body::empty(),
1348+
))
1349+
.await
1350+
.unwrap();
1351+
assert!(
1352+
!resp.status().is_success(),
1353+
"merge must not succeed for source_branch=@, got {}",
1354+
resp.status()
1355+
);
1356+
1357+
let stored = state
1358+
.db
1359+
.get_pr(&repo.id, 1)
1360+
.await
1361+
.expect("get_pr")
1362+
.expect("pr row");
1363+
assert_eq!(
1364+
stored.status, "open",
1365+
"a rejected merge must not record pull_request.merged"
1366+
);
1367+
}
1368+
12431369
/// SINK GUARD: legacy target_branch=HEAD must be rejected before worktree add.
12441370
#[sqlx::test]
12451371
async fn legacy_pr_row_with_head_target_is_rejected_at_merge_sink(pool: PgPool) {

0 commit comments

Comments
 (0)