@@ -109,12 +109,31 @@ impl Database {
109109 Ok ( ( ) )
110110 }
111111
112- /// Checkout the current HEAD into the working tree.
112+ /// Fast-forward HEAD to the remote tracking branch, then checkout the working tree.
113113 fn checkout_head ( & self ) -> Result < bool , DatabaseError > {
114114 let repo = gix:: open ( & self . path ) . map_err ( |e| DatabaseError :: Git ( e. to_string ( ) ) ) ?;
115- let tree = repo
115+
116+ // Find the remote tracking branch (e.g. origin/main) and fast-forward HEAD to it.
117+ let remote_commit = self . find_remote_head ( & repo) ?;
118+ let head_commit = repo
116119 . head_commit ( )
117- . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?
120+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?;
121+
122+ if remote_commit. id != head_commit. id {
123+ // Update HEAD (and the branch it points to) to the remote commit.
124+ repo. reference (
125+ repo. head_name ( )
126+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?
127+ . ok_or_else ( || DatabaseError :: UpdateFailed ( "detached HEAD" . to_string ( ) ) ) ?
128+ . as_ref ( ) ,
129+ remote_commit. id ,
130+ gix:: refs:: transaction:: PreviousValue :: MustExist ,
131+ "gem-audit update" ,
132+ )
133+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?;
134+ }
135+
136+ let tree = remote_commit
118137 . tree ( )
119138 . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?;
120139
@@ -145,6 +164,35 @@ impl Database {
145164 Ok ( true )
146165 }
147166
167+ /// Resolve the remote tracking commit (e.g. `origin/main`) to fast-forward to.
168+ ///
169+ /// If neither `origin/main` nor `origin/master` is found, returns `Err`;
170+ /// the caller (`update`) will then fall back to a fresh clone via `reclone`.
171+ fn find_remote_head < ' a > (
172+ & self ,
173+ repo : & ' a gix:: Repository ,
174+ ) -> Result < gix:: Commit < ' a > , DatabaseError > {
175+ // Try well-known remote tracking refs in order of likelihood.
176+ let candidates = [ "refs/remotes/origin/main" , "refs/remotes/origin/master" ] ;
177+
178+ for refname in & candidates {
179+ if let Ok ( reference) = repo. find_reference ( * refname) {
180+ let commit = reference
181+ . into_fully_peeled_id ( )
182+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?
183+ . object ( )
184+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?
185+ . try_into_commit ( )
186+ . map_err ( |e| DatabaseError :: UpdateFailed ( e. to_string ( ) ) ) ?;
187+ return Ok ( commit) ;
188+ }
189+ }
190+
191+ Err ( DatabaseError :: UpdateFailed (
192+ "no remote tracking branch found (tried origin/main, origin/master)" . to_string ( ) ,
193+ ) )
194+ }
195+
148196 /// Delete the existing DB and re-clone from scratch.
149197 ///
150198 /// Uses an atomic swap: clone to a sibling `_tmp` directory, rename the
@@ -249,6 +297,10 @@ impl Database {
249297 let mut results = Vec :: new ( ) ;
250298 let gem_dir = self . path . join ( "gems" ) . join ( gem_name) ;
251299
300+ if !is_contained_in ( & gem_dir, & self . path ) {
301+ return ( results, 0 ) ;
302+ }
303+
252304 let errors = if gem_dir. is_dir ( ) {
253305 self . load_advisories_from_dir ( & gem_dir, & mut results)
254306 } else {
@@ -281,6 +333,10 @@ impl Database {
281333 let mut results = Vec :: new ( ) ;
282334 let engine_dir = self . path . join ( "rubies" ) . join ( engine) ;
283335
336+ if !is_contained_in ( & engine_dir, & self . path ) {
337+ return ( results, 0 ) ;
338+ }
339+
284340 let errors = if engine_dir. is_dir ( ) {
285341 self . load_advisories_from_dir ( & engine_dir, & mut results)
286342 } else {
@@ -366,6 +422,27 @@ impl fmt::Display for Database {
366422 }
367423}
368424
425+ /// Check that `child` is logically contained within `parent` after normalising
426+ /// `..` components. This prevents path traversal via crafted gem/engine names.
427+ fn is_contained_in ( child : & Path , parent : & Path ) -> bool {
428+ use std:: path:: Component ;
429+
430+ let mut depth: usize = 0 ;
431+ for component in child. strip_prefix ( parent) . unwrap_or ( child) . components ( ) {
432+ match component {
433+ Component :: ParentDir => {
434+ if depth == 0 {
435+ return false ;
436+ }
437+ depth -= 1 ;
438+ }
439+ Component :: Normal ( _) => depth += 1 ,
440+ _ => { }
441+ }
442+ }
443+ true
444+ }
445+
369446/// Fallback for getting the default database path when the `dirs` crate is not available.
370447fn dirs_fallback ( ) -> PathBuf {
371448 if let Ok ( home) = std:: env:: var ( "HOME" ) {
@@ -464,7 +541,7 @@ mod tests {
464541
465542 #[ test]
466543 fn open_fixture_advisory_dir ( ) {
467- let ( tmp, _) = temp_mock_db ( "fixture" ) ;
544+ let ( tmp, _) = temp_mock_db ( ) ;
468545
469546 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
470547 assert ! ( !db. is_git( ) ) ;
@@ -503,7 +580,7 @@ mod tests {
503580
504581 // Helper: create an isolated temporary mock DB for tests that don't
505582 // share state with `mock_database()` in scanner tests.
506- fn temp_mock_db ( _suffix : & str ) -> ( tempfile:: TempDir , PathBuf ) {
583+ fn temp_mock_db ( ) -> ( tempfile:: TempDir , PathBuf ) {
507584 let fixture_dir = PathBuf :: from ( env ! ( "CARGO_MANIFEST_DIR" ) ) . join ( "tests/fixtures" ) ;
508585 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
509586 let gem_dir = tmp. path ( ) . join ( "gems" ) . join ( "test" ) ;
@@ -520,7 +597,7 @@ mod tests {
520597
521598 #[ test]
522599 fn database_display ( ) {
523- let ( tmp, _) = temp_mock_db ( "display" ) ;
600+ let ( tmp, _) = temp_mock_db ( ) ;
524601 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
525602 let display = db. to_string ( ) ;
526603 assert_eq ! ( display, tmp. path( ) . to_string_lossy( ) ) ;
@@ -530,7 +607,7 @@ mod tests {
530607
531608 #[ test]
532609 fn database_exists_with_gems ( ) {
533- let ( tmp, _) = temp_mock_db ( "exists" ) ;
610+ let ( tmp, _) = temp_mock_db ( ) ;
534611 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
535612 assert ! ( db. exists( ) ) ;
536613 assert ! ( db. path( ) == tmp. path( ) ) ;
@@ -540,7 +617,7 @@ mod tests {
540617
541618 #[ test]
542619 fn database_advisories_with_mock ( ) {
543- let ( tmp, _) = temp_mock_db ( "advisories" ) ;
620+ let ( tmp, _) = temp_mock_db ( ) ;
544621 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
545622 let all = db. advisories ( ) ;
546623 assert_eq ! ( all. len( ) , 1 ) ;
@@ -549,7 +626,7 @@ mod tests {
549626
550627 #[ test]
551628 fn database_size_with_mock ( ) {
552- let ( tmp, _) = temp_mock_db ( "size" ) ;
629+ let ( tmp, _) = temp_mock_db ( ) ;
553630 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
554631 assert_eq ! ( db. size( ) , 1 ) ;
555632 }
@@ -606,7 +683,7 @@ mod tests {
606683
607684 #[ test]
608685 fn commit_id_none_for_non_git ( ) {
609- let ( tmp, _) = temp_mock_db ( "nongit" ) ;
686+ let ( tmp, _) = temp_mock_db ( ) ;
610687 let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
611688 assert_eq ! ( db. commit_id( ) , None ) ;
612689 assert_eq ! ( db. last_updated_at( ) , None ) ;
@@ -639,4 +716,39 @@ mod tests {
639716 let err = DatabaseError :: Git ( "corrupt repo" . to_string ( ) ) ;
640717 assert ! ( err. to_string( ) . contains( "git error" ) ) ;
641718 }
719+
720+ // ========== Path traversal guard ==========
721+
722+ #[ test]
723+ fn is_contained_in_normal_path ( ) {
724+ let parent = Path :: new ( "/db" ) ;
725+ assert ! ( is_contained_in( & parent. join( "gems" ) . join( "rails" ) , parent) ) ;
726+ }
727+
728+ #[ test]
729+ fn is_contained_in_rejects_traversal ( ) {
730+ let parent = Path :: new ( "/db" ) ;
731+ assert ! ( !is_contained_in(
732+ & parent. join( "gems" ) . join( ".." ) . join( ".." ) . join( "etc" ) ,
733+ parent
734+ ) ) ;
735+ }
736+
737+ #[ test]
738+ fn advisories_for_traversal_gem_returns_empty ( ) {
739+ let ( tmp, _) = temp_mock_db ( ) ;
740+ let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
741+ let ( advisories, errors) = db. advisories_for_with_errors ( "../../etc" ) ;
742+ assert ! ( advisories. is_empty( ) ) ;
743+ assert_eq ! ( errors, 0 ) ;
744+ }
745+
746+ #[ test]
747+ fn advisories_for_ruby_traversal_returns_empty ( ) {
748+ let ( tmp, _) = temp_mock_db ( ) ;
749+ let db = Database :: open ( tmp. path ( ) ) . unwrap ( ) ;
750+ let ( advisories, errors) = db. advisories_for_ruby_with_errors ( "../../etc" ) ;
751+ assert ! ( advisories. is_empty( ) ) ;
752+ assert_eq ! ( errors, 0 ) ;
753+ }
642754}
0 commit comments