@@ -11,7 +11,7 @@ use mdbook_core::config::{BookConfig, Config, HtmlConfig};
1111use mdbook_core:: utils:: fs;
1212use mdbook_renderer:: { RenderContext , Renderer } ;
1313use serde_json:: json;
14- use std:: collections:: { BTreeMap , HashMap } ;
14+ use std:: collections:: { BTreeMap , HashMap , HashSet } ;
1515use std:: path:: { Path , PathBuf } ;
1616use tracing:: error;
1717use tracing:: { debug, info, trace, warn} ;
@@ -245,6 +245,7 @@ impl HtmlHandlebars {
245245 }
246246
247247 debug ! ( "Emitting redirects" ) ;
248+ validate_redirect_loops ( redirects) ?;
248249 let redirects = combine_fragment_redirects ( redirects) ;
249250
250251 for ( original, ( dest, fragment_map) ) in redirects {
@@ -547,6 +548,7 @@ fn make_data(
547548 data. insert ( "print_enable" . to_owned ( ) , json ! ( html_config. print. enable) ) ;
548549 data. insert ( "fold_enable" . to_owned ( ) , json ! ( html_config. fold. enable) ) ;
549550 data. insert ( "fold_level" . to_owned ( ) , json ! ( html_config. fold. level) ) ;
551+ data. insert ( "fold_headers" . to_owned ( ) , json ! ( html_config. fold. headers) ) ;
550552 data. insert (
551553 "sidebar_header_nav" . to_owned ( ) ,
552554 json ! ( html_config. sidebar_header_nav) ,
@@ -639,6 +641,75 @@ struct RenderChapterContext<'a> {
639641 chapter_titles : & ' a HashMap < PathBuf , String > ,
640642}
641643
644+ /// Returns the canonical redirect map key (leading `/`, no leading `./`).
645+ fn redirect_lookup_key ( path : & str ) -> String {
646+ let path = path. trim_start_matches ( '/' ) ;
647+ format ! ( "/{path}" )
648+ }
649+
650+ fn is_external_redirect ( url : & str ) -> bool {
651+ let url = url. trim ( ) ;
652+ url. starts_with ( "http://" ) || url. starts_with ( "https://" ) || url. starts_with ( "//" )
653+ }
654+
655+ fn find_redirect_cycle ( start : & str , redirects : & HashMap < String , String > ) -> Option < Vec < String > > {
656+ let mut chain = vec ! [ start. to_string( ) ] ;
657+ let mut current = start. to_string ( ) ;
658+
659+ loop {
660+ let dest = redirects. get ( & current) ?;
661+ if is_external_redirect ( dest) {
662+ return None ;
663+ }
664+
665+ let next = redirect_lookup_key ( dest) ;
666+ if let Some ( loop_start) = chain. iter ( ) . position ( |node| node == & next) {
667+ let mut cycle = chain[ loop_start..] . to_vec ( ) ;
668+ cycle. push ( next) ;
669+ return Some ( cycle) ;
670+ }
671+ chain. push ( next. clone ( ) ) ;
672+ current = next;
673+ }
674+ }
675+
676+ /// Rotates a redirect cycle so the lexicographically smallest node is first.
677+ fn canonicalize_redirect_cycle ( cycle : & [ String ] ) -> Vec < String > {
678+ if cycle. len ( ) <= 1 {
679+ return cycle. to_vec ( ) ;
680+ }
681+
682+ let nodes = & cycle[ ..cycle. len ( ) - 1 ] ;
683+ let min_idx = nodes
684+ . iter ( )
685+ . enumerate ( )
686+ . min_by_key ( |( _, node) | node. as_str ( ) )
687+ . map ( |( idx, _) | idx)
688+ . unwrap_or ( 0 ) ;
689+
690+ let mut rotated = nodes[ min_idx..] . to_vec ( ) ;
691+ rotated. extend_from_slice ( & nodes[ ..min_idx] ) ;
692+ rotated. push ( rotated[ 0 ] . clone ( ) ) ;
693+ rotated
694+ }
695+
696+ /// Detects cycles in `[output.html.redirect]` before emitting redirect pages.
697+ fn validate_redirect_loops ( redirects : & HashMap < String , String > ) -> Result < ( ) > {
698+ let mut reported = HashSet :: new ( ) ;
699+
700+ for start in redirects. keys ( ) {
701+ let Some ( cycle) = find_redirect_cycle ( start, redirects) else {
702+ continue ;
703+ } ;
704+ let canonical = canonicalize_redirect_cycle ( & cycle) ;
705+ let signature: Vec < _ > = canonical[ ..canonical. len ( ) - 1 ] . to_vec ( ) ;
706+ if reported. insert ( signature) {
707+ bail ! ( "redirect loop detected: {}" , canonical. join( " → " ) ) ;
708+ }
709+ }
710+ Ok ( ( ) )
711+ }
712+
642713/// Redirect mapping.
643714///
644715/// The key is the source path (like `foo/bar.html`). The value is a tuple
@@ -694,3 +765,49 @@ fn collect_redirects_for_path(
694765 . collect ( ) ;
695766 Ok ( map)
696767}
768+
769+ #[ cfg( test) ]
770+ mod redirect_loop_tests {
771+ use super :: * ;
772+
773+ #[ test]
774+ fn detects_fragment_redirect_loop ( ) {
775+ let redirects = HashMap :: from ( [
776+ (
777+ "/chapter_1.html#a" . to_string ( ) ,
778+ "chapter_2.html#b" . to_string ( ) ,
779+ ) ,
780+ (
781+ "/chapter_2.html#b" . to_string ( ) ,
782+ "chapter_1.html#a" . to_string ( ) ,
783+ ) ,
784+ ] ) ;
785+ let err = validate_redirect_loops ( & redirects) . unwrap_err ( ) ;
786+ assert ! ( err. to_string( ) . contains( "redirect loop detected" ) ) ;
787+ }
788+
789+ #[ test]
790+ fn detects_page_redirect_loop ( ) {
791+ let redirects = HashMap :: from ( [
792+ ( "/a.html" . to_string ( ) , "b.html" . to_string ( ) ) ,
793+ ( "/b.html" . to_string ( ) , "a.html" . to_string ( ) ) ,
794+ ] ) ;
795+ let err = validate_redirect_loops ( & redirects) . unwrap_err ( ) ;
796+ assert ! ( err. to_string( ) . contains( "/a.html" ) ) ;
797+ }
798+
799+ #[ test]
800+ fn allows_acyclic_redirect_chain ( ) {
801+ let redirects = HashMap :: from ( [
802+ ( "/a.html" . to_string ( ) , "b.html" . to_string ( ) ) ,
803+ ( "/b.html" . to_string ( ) , "c.html" . to_string ( ) ) ,
804+ ] ) ;
805+ validate_redirect_loops ( & redirects) . unwrap ( ) ;
806+ }
807+
808+ #[ test]
809+ fn stops_at_external_redirect ( ) {
810+ let redirects = HashMap :: from ( [ ( "/a.html" . to_string ( ) , "https://example.com" . to_string ( ) ) ] ) ;
811+ validate_redirect_loops ( & redirects) . unwrap ( ) ;
812+ }
813+ }
0 commit comments