This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch feat/oink-core-platform in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit d3cf6cc1ce74528320ad279ac127513e58241f1b Author: dark <[email protected]> AuthorDate: Fri Sep 4 21:46:08 2026 +0800 fix(versioning): preserve staged history routes - keep historical selectors on the configured production origin - scope generated text corpus URLs for staging validation - reject parent symlinks before resolving removable outputs - cover selector and sentinel regressions --- scripts/test_versioning.py | 54 ++++++++++++++++++++++++++++++++++++++++++ scripts/versioning.py | 59 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py index e27982391..2ae034f9e 100644 --- a/scripts/test_versioning.py +++ b/scripts/test_versioning.py @@ -782,6 +782,40 @@ class VersionUrlTest(unittest.TestCase): allowed_paths=ALLOWED_PATHS, ) + def test_latest_staging_scope_preserves_production_history_selector(self) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + latest = manifest["versions"][0] + with tempfile.TemporaryDirectory() as temp_name: + output = Path(temp_name) + history_url = f"{ORIGIN}versions/1.7/docs/" + page = output / "index.html" + page.write_text( + f'<a href="{ORIGIN}docs/">latest</a>' + f'<a href="{history_url}">1.7</a>', + encoding="utf-8", + ) + llms = output / "llms-full.txt" + llms.write_text( + f"# Corpus\n\n- [Latest]({ORIGIN}docs/)\n" + f"- [1.7]({history_url})\n", + encoding="utf-8", + ) + + versioning.scope_version_artifact( + output, + manifest, + latest, + STAGING_ORIGIN, + historical_origin=ORIGIN, + ) + + rendered = page.read_text(encoding="utf-8") + self.assertIn(f'href="{STAGING_ORIGIN}docs/"', rendered) + self.assertIn(f'href="{history_url}"', rendered) + rendered_llms = llms.read_text(encoding="utf-8") + self.assertIn(f"]({STAGING_ORIGIN}docs/)", rendered_llms) + self.assertIn(f"]({history_url})", rendered_llms) + def test_rejects_non_selector_cross_version_url(self) -> None: with self.assertRaises(SystemExit): rewrite("/versions/1.5/docs/config/") @@ -1200,6 +1234,26 @@ class VersionUrlTest(unittest.TestCase): versioning.prepare_output_directory(sibling, "fixture") remove.assert_not_called() + def test_output_cleanup_rejects_existing_parent_symlink_before_resolve( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + target = temp / "target" + target.mkdir() + linked_parent = temp / "linked-parent" + linked_parent.symlink_to(target, target_is_directory=True) + output = linked_parent / "output" + output.mkdir() + sentinel = output / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + + with self.assertRaisesRegex(SystemExit, "symbolic link"): + versioning.prepare_output_directory(output, "fixture") + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertTrue(output.is_dir()) + def test_output_cleanup_rejects_registered_sibling_worktree(self) -> None: with tempfile.TemporaryDirectory() as temp_name: sibling = Path(temp_name) / "registered-sibling" diff --git a/scripts/versioning.py b/scripts/versioning.py index 5c902ae68..a545cd889 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -316,18 +316,45 @@ def require_output_outside_git_checkouts(output: pathlib.Path, label: str) -> No fail(f"{label} must be outside every Git checkout: {output}") +def require_no_symlinked_output_components( + path: pathlib.Path, + label: str, + controlled_roots: set[pathlib.Path], +) -> pathlib.Path: + """Reject existing symlinks below a trusted temp root before resolution.""" + absolute = pathlib.Path(os.path.abspath(os.fspath(path))) + lexical_roots = { + pathlib.Path(os.path.abspath(os.fspath(root))) for root in controlled_roots + } + lexical_roots.update(root.resolve() for root in controlled_roots) + anchors = [ + root + for root in lexical_roots + if absolute == root or root in absolute.parents + ] + anchor = max(anchors, key=lambda item: len(item.parts)) if anchors else None + candidate = absolute + while candidate != anchor and candidate != candidate.parent: + if candidate.is_symlink(): + fail(f"{label} must not contain a symbolic link: {candidate}") + candidate = candidate.parent + return absolute + + def prepare_output_directory(path: pathlib.Path, label: str) -> pathlib.Path: raw = path.expanduser() - if raw.is_symlink(): - fail(f"{label} must not be a symbolic link: {raw}") - output = raw.resolve() - allowed_roots = { - pathlib.Path(tempfile.gettempdir()).resolve(), - pathlib.Path("/tmp").resolve(), + controlled_roots = { + pathlib.Path(tempfile.gettempdir()), + pathlib.Path("/tmp"), } runner_temp = os.environ.get("RUNNER_TEMP") if runner_temp: - allowed_roots.add(pathlib.Path(runner_temp).resolve()) + controlled_roots.add(pathlib.Path(runner_temp)) + raw_absolute = require_no_symlinked_output_components( + raw, label, controlled_roots + ) + output = raw_absolute.resolve() + allowed_roots = {root.resolve() for root in controlled_roots} if not any(root != output and root in output.parents for root in allowed_roots): fail(f"{label} must be below a controlled temporary directory: {output}") require_output_outside_git_checkouts(output, label) @@ -1902,14 +1929,27 @@ def scope_version_artifact( manifest: dict, entry: dict, origin: str, + historical_origin: str | None = None, ) -> dict: """Repair URL fields Hugo cannot canonify, then return auditable counts.""" if not entry["publishPath"] and origin.rstrip("/") == CANONICAL_ORIGIN.rstrip("/"): return {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0} allowed_paths = allowed_version_paths(manifest) artifact_base = base_url(origin, entry["publishPath"]) + historical_selector_urls: set[str] = set() + if historical_origin is not None: + for language in ("en", "cn"): + for item in version_urls( + manifest, origin, language, historical_origin + ): + if item["version"] != "latest": + historical_selector_urls.update( + (item["url"], item["url"].rstrip("/")) + ) def rewrite(value: str) -> str: + if value in historical_selector_urls: + return value rewritten = rewrite_internal_url( value, origin=origin, @@ -1999,13 +2039,13 @@ def scope_version_artifact( ) stats["files"] += 1 continue - if path.suffix not in {".html", ".md", ".xml"}: + if path.suffix not in {".html", ".md", ".xml", ".txt"}: continue original = path.read_text(encoding="utf-8") rendered, changed = rewrite_text_urls( original, rewrite, - markdown=path.suffix == ".md", + markdown=path.suffix in {".md", ".txt"}, ) def replace_manifest(match: re.Match) -> str: @@ -3021,6 +3061,7 @@ def build(args: argparse.Namespace) -> None: manifest, entry, args.site_origin, + args.historical_origin, ) url_scoping["historicalHomeRedirects"] = ( write_historical_home_redirects(output, args.site_origin)
