From 215d7faa19ce2df0911993c79ddc0d16bbd12ddc Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 9 Sep 2026 21:52:25 +0200 Subject: [PATCH] feature: resolve redirect chains and reject cycles Signed-off-by: squidfunk --- .../compat/mkdocs/plugin/redirects/plan.rs | 82 ++++++++++++++++++- python/tests/integration/test_redirects.py | 47 +++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs index 7056f1d..6f49504 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs @@ -114,6 +114,8 @@ pub struct Redirect { /// One validated configuration entry awaiting target resolution. #[derive(Clone, Debug)] struct Specification { + /// Original configured source used to resolve redirect chains. + configured_source: String, /// Prepared page or anchor source. source: Source, /// Prepared internal or external target. @@ -198,15 +200,25 @@ impl Plan { Target::External(configured_target.clone()) } else { let (source, fragment) = split_fragment(configured_target); - plan.targets.insert(source.into()); Target::Internal { configured: configured_target.clone(), source: source.into(), fragment: fragment.into(), } }; - plan.specifications.push(Specification { source, target }); + plan.specifications.push(Specification { + configured_source: configured_source.clone(), + source, + target, + }); } + resolve_chains(&mut plan.specifications)?; + plan.targets.extend(plan.specifications.iter().filter_map( + |specification| match &specification.target { + Target::Internal { source, .. } => Some(source.clone()), + Target::External(_) => None, + }, + )); Ok(plan) } } @@ -324,6 +336,72 @@ impl Snapshot { // Functions // ---------------------------------------------------------------------------- +/// Resolves configured redirect chains and rejects cycles. +fn resolve_chains(specifications: &mut [Specification]) -> Result<()> { + let sources = specifications + .iter() + .enumerate() + .map(|(index, specification)| { + (specification.configured_source.clone(), index) + }) + .collect::>(); + let mut resolved = vec![None; specifications.len()]; + for index in 0..specifications.len() { + resolve_chain( + index, + specifications, + &sources, + &mut Vec::new(), + &mut resolved, + )?; + } + for (specification, target) in specifications.iter_mut().zip(resolved) { + specification.target = target.expect("every redirect was resolved"); + } + Ok(()) +} + +/// Resolves one redirect target recursively against configured sources. +fn resolve_chain( + index: usize, specifications: &[Specification], + sources: &BTreeMap, visiting: &mut Vec, + resolved: &mut [Option], +) -> Result { + if let Some(target) = &resolved[index] { + return Ok(target.clone()); + } + if let Some(position) = visiting.iter().position(|other| *other == index) { + let cycle = visiting[position..] + .iter() + .chain(std::iter::once(&index)) + .map(|index| specifications[*index].configured_source.as_str()) + .collect::>() + .join(" -> "); + bail!("redirect cycle detected: {cycle}") + } + + visiting.push(index); + let target = match &specifications[index].target { + Target::Internal { configured, .. } => { + if let Some(next) = sources.get(configured) { + resolve_chain( + *next, + specifications, + sources, + visiting, + resolved, + )? + } else { + specifications[index].target.clone() + } + } + Target::External(_) => specifications[index].target.clone(), + }; + visiting.pop(); + resolved[index] = Some(target.clone()); + Ok(target) +} + /// Rejects redirect sources that could escape the site directory. fn normalize_source(source: &str) -> Result { if source.is_empty() || source.contains('\\') { diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py index 77b2a0f..76e48e5 100644 --- a/python/tests/integration/test_redirects.py +++ b/python/tests/integration/test_redirects.py @@ -140,6 +140,53 @@ def test_page_redirects_override_configured_anchor_targets( } +def test_redirect_chains_resolve_to_final_targets(tmp_path: Path) -> None: + """Page, anchor, and mixed chains emit their final destinations.""" + config = _write_project( + tmp_path, + """\ + first.md: second.md + second.md: new.md + first.md#install: new.md#old + new.md#old: new.md#intermediate + new.md#intermediate: guide/topic.md#details +""", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + + first = (tmp_path / "site" / "first" / "index.html").read_text() + second = (tmp_path / "site" / "second" / "index.html").read_text() + assert '' in first + assert '' in second + assert 'redirects={"#install":"../guide/topic/#details"}' in first + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "first/#install": "guide/topic/#details", + "new/#intermediate": "guide/topic/#details", + "new/#old": "guide/topic/#details", + } + + +@pytest.mark.parametrize( + "redirect_maps", + [ + " old.md: old.md\n", + " first.md: second.md\n second.md: first.md\n", + ( + " new.md#first: new.md#second\n" + " new.md#second: new.md#first\n" + ), + " old.md: new.md#old\n new.md#old: old.md\n", + ], +) +def test_redirect_cycles_are_rejected( + tmp_path: Path, redirect_maps: str +) -> None: + """Self, page, anchor, and mixed redirect cycles fail planning.""" + config = _write_project(tmp_path, redirect_maps) + with pytest.raises(RuntimeError, match="redirect cycle detected"): + zensical.build(str(config), _BUILD_OPTIONS) + + def test_redirects_without_directory_urls_write_html_files( tmp_path: Path, ) -> None: