@@ -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+
788858fn 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) ]
930998mod 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 ;
0 commit comments