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)

Reply via email to