This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch feat/oink-community-content
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit 7851fffbd897699cc1bbd3fd2c637cb549d67600
Author: dark <[email protected]>
AuthorDate: Fri Sep 4 20:42:44 2026 +0800

    fix(community): harden roster contracts
    
    - reject unsafe paths, redirects, and malformed source schemas
    - validate member identities and role-local rendered links
    - preserve Markdown section boundaries across output formats
    - add focused regressions for each reviewer finding
---
 layouts/community/landing.md     |  13 ++--
 scripts/community_roster.py      | 140 ++++++++++++++++++++++++++++++++++-----
 scripts/test_community_roster.py | 117 +++++++++++++++++++++++++++++++-
 3 files changed, 245 insertions(+), 25 deletions(-)

diff --git a/layouts/community/landing.md b/layouts/community/landing.md
index b93fe79d8..e884af8fe 100644
--- a/layouts/community/landing.md
+++ b/layouts/community/landing.md
@@ -5,24 +5,25 @@
 {{- .Store.Set "tdOutputFormat" "markdown" -}}
 # {{ .Title }}
 
-{{- with .Description }}
+{{ with .Description }}
 > {{ . }}
 
-{{- end }}
+{{ end }}
 
-{{- $page := . -}}
+{{ $page := . -}}
 {{- $landing := partial "landing/data.html" . -}}
+{{- $chunks := slice -}}
 {{- range $entry := ($landing.sections | default slice) -}}
   {{- $resolved := partial "landing/entry.html" (dict "home" $landing "entry" 
$entry) -}}
   {{- if and $resolved.enabled $resolved.data -}}
     {{- if eq $resolved.type "community-members" -}}
-      {{- partial "community/members.md" (dict "page" $page) -}}
+      {{- $chunks = $chunks | append (partial "community/members.md" (dict 
"page" $page) | strings.TrimSpace) -}}
     {{- else -}}
       {{- $text := partial "landing/text.html" (dict "page" $page "data" (dict 
"sections" (slice $entry))) | strings.TrimSpace -}}
       {{- with $text }}
-{{ . | safeHTML }}
-
+        {{- $chunks = $chunks | append . -}}
       {{- end -}}
     {{- end -}}
   {{- end -}}
 {{- end -}}
+{{ delimit $chunks "\n\n" | safeHTML }}
diff --git a/scripts/community_roster.py b/scripts/community_roster.py
index d7e05410f..54afcb0c3 100644
--- a/scripts/community_roster.py
+++ b/scripts/community_roster.py
@@ -44,16 +44,50 @@ class RosterError(ValueError):
 
 def _read_json(path: pathlib.Path) -> dict:
     try:
-        return json.loads(path.read_text(encoding="utf-8"))
+        result = json.loads(path.read_text(encoding="utf-8"))
     except (OSError, json.JSONDecodeError) as exc:
-        raise RosterError(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") 
from exc
+        raise RosterError(f"{path}: invalid JSON: {exc}") from exc
+    if not isinstance(result, dict):
+        raise RosterError(f"{path}: JSON root must be an object")
+    return result
+
+
+def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> 
None:
+    try:
+        parsed = urllib.parse.urlparse(url)
+        port = parsed.port
+    except ValueError as exc:
+        raise RosterError(f"{kind}: malformed URL: {url}") from exc
+    if (
+        parsed.scheme != "https"
+        or parsed.hostname not in expected_hosts
+        or port not in (None, 443)
+        or parsed.username is not None
+        or parsed.password is not None
+    ):
+        raise RosterError(f"{kind}: URL is not allowlisted: {url}")
+
+
+class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler):
+    def __init__(self, expected_hosts: set[str], kind: str):
+        super().__init__()
+        self.expected_hosts = expected_hosts
+        self.kind = kind
+
+    def redirect_request(self, request, fp, code, msg, headers, newurl):
+        _validate_remote_url(newurl, self.expected_hosts, self.kind)
+        return super().redirect_request(request, fp, code, msg, headers, 
newurl)
+
+
+def _open_allowlisted(request: urllib.request.Request, expected_hosts: 
set[str], kind: str):
+    _validate_remote_url(request.full_url, expected_hosts, kind)
+    opener = 
urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind))
+    return opener.open(request, timeout=30)
 
 
 def _read_bounded_response(response, *, expected_hosts: set[str], 
content_types: set[str], limit: int, kind: str) -> bytes:
     final_url = response.geturl()
-    parsed = urllib.parse.urlparse(final_url)
-    if parsed.scheme != "https" or parsed.hostname not in expected_hosts:
-        raise RosterError(f"{kind}: redirect target is not allowlisted: 
{final_url}")
+    _validate_remote_url(final_url, expected_hosts, kind)
     content_type = response.headers.get("Content-Type", "").split(";", 
1)[0].strip().lower()
     if content_type not in content_types and not (kind == "JSON source" and 
content_type.endswith("+json")):
         raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}")
@@ -65,7 +99,7 @@ def _read_bounded_response(response, *, expected_hosts: 
set[str], content_types:
 
 def _fetch_json(url: str) -> dict:
     request = urllib.request.Request(url, headers={"User-Agent": 
"apache-hugegraph-doc-community-roster/1"})
-    with urllib.request.urlopen(request, timeout=30) as response:
+    with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as 
response:
         if response.status != 200:
             raise RosterError(f"{url}: HTTP {response.status}")
         raw = _read_bounded_response(
@@ -87,7 +121,10 @@ def _fetch_json(url: str) -> dict:
 def _person_name(people: dict, asf_id: str) -> str:
     if not ASF_ID_PATTERN.fullmatch(asf_id):
         raise RosterError(f"invalid ASF ID {asf_id!r}")
-    record = people.get("people", {}).get(asf_id)
+    records = people.get("people")
+    if not isinstance(records, dict):
+        raise RosterError("people source must contain a people object")
+    record = records.get(asf_id)
     name = record.get("name") if isinstance(record, dict) else None
     if isinstance(name, list):
         name = name[0] if name else ""
@@ -231,7 +268,7 @@ def _avatar_bytes(user_id: int) -> bytes:
         f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4";,
         headers={"Accept": "image/webp", "User-Agent": 
"apache-hugegraph-doc-community-roster/1"},
     )
-    with urllib.request.urlopen(request, timeout=30) as response:
+    with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub 
avatar") as response:
         raw = _read_bounded_response(
             response,
             expected_hosts={"avatars.githubusercontent.com"},
@@ -279,18 +316,26 @@ def _member(asf_id: str, name: str, chair: bool, mapping: 
dict | None) -> dict:
 
 
 def build_roster(committee_data: dict, projects_data: dict, people_data: dict, 
mapping_data: dict) -> dict:
-    project = projects_data.get("projects", {}).get(PROJECT)
-    committee = committee_data.get("committees", {}).get(PROJECT)
+    projects = projects_data.get("projects")
+    committees = committee_data.get("committees")
+    if not isinstance(projects, dict) or not isinstance(committees, dict):
+        raise RosterError("ASF sources must contain projects and committees 
objects")
+    project = projects.get(PROJECT)
+    committee = committees.get(PROJECT)
     if not isinstance(project, dict) or not isinstance(committee, dict):
         raise RosterError("ASF sources do not contain the HugeGraph project")
     owners, members = project.get("owners"), project.get("members")
     chair_map, committee_roster = committee.get("chair"), 
committee.get("roster")
     if not isinstance(owners, list) or not isinstance(members, list):
         raise RosterError("LDAP project owners/members must be arrays")
+    if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for 
item in owners + members):
+        raise RosterError("LDAP project owners/members contain an invalid ASF 
ID")
     if not isinstance(chair_map, dict) or len(chair_map) != 1:
         raise RosterError("committee source must name exactly one Chair")
     if not isinstance(committee_roster, dict):
         raise RosterError("committee roster must be an object")
+    if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for 
item in [*chair_map, *committee_roster]):
+        raise RosterError("committee source contains an invalid ASF ID")
     owner_ids, member_ids = set(owners), set(members)
     chair = next(iter(chair_map))
     if not owner_ids <= member_ids:
@@ -337,6 +382,7 @@ def _install_avatars(candidate: dict, target: pathlib.Path) 
-> None:
 
 
 def validate_bundle(warn_after_days: int) -> list[str]:
+    _validate_repo_paths()
     roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH)
     if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") 
!= PROJECT:
         raise RosterError("roster.json: unsupported schema_version or project")
@@ -351,10 +397,10 @@ def validate_bundle(warn_after_days: int) -> list[str]:
     owners, members, chair = source.get("owners"), source.get("members"), 
source.get("chair")
     if not isinstance(owners, list) or not isinstance(members, list):
         raise RosterError("roster.json: source owners/members must be arrays")
+    if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) 
for asf_id in owners + members):
+        raise RosterError("roster.json: source owners/members contain an 
invalid ASF ID")
     if owners != sorted(set(owners)) or members != sorted(set(members)):
         raise RosterError("roster.json: source owners/members must be sorted 
and unique")
-    if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) 
for asf_id in members):
-        raise RosterError("roster.json: source members contain an invalid ASF 
ID")
     if not set(owners) <= set(members) or chair not in owners:
         raise RosterError("roster.json: invalid owners/members/Chair 
relationship")
     pmc, committers = roles["pmc"], roles["committers"]
@@ -379,6 +425,10 @@ def validate_bundle(warn_after_days: int) -> list[str]:
         for person in entries:
             if any(key not in person for key in ("asf_id", "name", "initials", 
"chair", "profile_url")):
                 raise RosterError(f"roster.json: incomplete member {person!r}")
+            if not isinstance(person["name"], str) or not 
person["name"].strip():
+                raise RosterError(f"roster.json: member name must be non-empty 
for {person['asf_id']!r}")
+            if person["initials"] != _initials(person["name"]):
+                raise RosterError(f"roster.json: member initials mismatch for 
{person['asf_id']!r}")
             if type(person["chair"]) is not bool:
                 raise RosterError(f"roster.json: chair must be boolean for 
{person['asf_id']!r}")
     mappings = _validate_mapping(mapping, set(ids))
@@ -424,7 +474,7 @@ def validate_rendered_outputs(destination: pathlib.Path) -> 
None:
         "cn/_print/community/index.html": ('data-community-role="pmc"', 
'data-community-role="committers"'),
         "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"),
     }
-    urls = [p["profile_url"] for role in ("pmc", "committers") for p in 
_read_json(ROSTER_PATH)["roles"][role]]
+    roster_roles = _read_json(ROSTER_PATH)["roles"]
     for relative, markers in expected.items():
         path = destination / relative
         if not path.is_file():
@@ -439,9 +489,38 @@ def validate_rendered_outputs(destination: pathlib.Path) 
-> None:
             has_markers = all(marker in rendered for marker in markers)
         if not has_markers:
             raise RosterError(f"rendered output {relative} is missing 
Community markers")
-        positions = [rendered.find(url.replace("&", "&amp;") if 
relative.endswith(".html") else url) for url in urls]
-        if any(position < 0 for position in positions) or positions != 
sorted(positions):
-            raise RosterError(f"rendered output {relative} has role/link 
parity drift")
+        role_starts = {}
+        for role in ("pmc", "committers"):
+            pattern = (
+                
rf'<section[^>]*data-community-role=(?:"{role}"|{role})(?:\s|>)'
+                if relative.endswith(".html")
+                else rf"(?m)^### {role.upper() if role == 'pmc' else 
'Committers'}\s*$"
+            )
+            match = re.search(pattern, rendered)
+            if not match:
+                raise RosterError(f"rendered output {relative} is missing 
{role} section")
+            role_starts[role] = match.start()
+        if role_starts["pmc"] >= role_starts["committers"]:
+            raise RosterError(f"rendered output {relative} has role order 
drift")
+        segments = {
+            "pmc": rendered[role_starts["pmc"] : role_starts["committers"]],
+            "committers": rendered[role_starts["committers"] :],
+        }
+        for role, entries in roster_roles.items():
+            own_urls = [
+                person["profile_url"].replace("&", "&amp;") if 
relative.endswith(".html") else person["profile_url"]
+                for person in entries
+            ]
+            other_role = "committers" if role == "pmc" else "pmc"
+            other_urls = [
+                person["profile_url"].replace("&", "&amp;") if 
relative.endswith(".html") else person["profile_url"]
+                for person in roster_roles[other_role]
+            ]
+            positions = [segments[role].find(url) for url in own_urls]
+            if any(position < 0 for position in positions) or positions != 
sorted(positions):
+                raise RosterError(f"rendered output {relative} has {role} link 
parity drift")
+            if any(url in segments[role] for url in other_urls):
+                raise RosterError(f"rendered output {relative} mixes Community 
roles")
 
 
 def _atomic_write(path: pathlib.Path, raw: bytes) -> None:
@@ -477,8 +556,36 @@ def _validate_avatar_blob(name: str, raw: bytes) -> None:
     _validate_webp(raw, expected_dimensions=(128, 128))
 
 
+def _assert_repo_path(path: pathlib.Path, label: str) -> None:
+    root = ROOT.absolute()
+    candidate = path.absolute()
+    try:
+        relative = candidate.relative_to(root)
+    except ValueError as exc:
+        raise RosterError(f"{label} must stay inside the repository") from exc
+    current = root
+    if current.is_symlink():
+        raise RosterError("repository root must not be a symlink")
+    for part in relative.parts:
+        current /= part
+        if current.is_symlink():
+            raise RosterError(f"{label} must not contain symlink path 
components")
+    try:
+        candidate.resolve(strict=False).relative_to(root.resolve(strict=True))
+    except (OSError, ValueError) as exc:
+        raise RosterError(f"{label} resolves outside the repository") from exc
+
+
+def _validate_repo_paths() -> None:
+    _assert_repo_path(DATA_DIR, "community data directory")
+    _assert_repo_path(ROSTER_PATH, "community roster")
+    _assert_repo_path(MAP_PATH, "GitHub mapping")
+    _assert_repo_path(AVATAR_DIR, "community avatar directory")
+
+
 def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> 
None:
     """Install verified immutable assets, then atomically publish the 
roster."""
+    _validate_repo_paths()
     AVATAR_DIR.mkdir(parents=True, exist_ok=True)
     referenced = {
         pathlib.PurePosixPath(person["avatar"]).name
@@ -522,6 +629,7 @@ def _commit_bundle(candidate: dict, candidate_avatars: 
dict[str, bytes]) -> None
 
 
 def refresh() -> None:
+    _validate_repo_paths()
     source_data = {key: _fetch_json(url) for key, url in SOURCES.items()}
     candidate = build_roster(source_data["committee"], 
source_data["projects"], source_data["people"], _read_json(MAP_PATH))
     work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", 
dir=DATA_DIR))
diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py
index 4101c72a9..4077079c2 100644
--- a/scripts/test_community_roster.py
+++ b/scripts/test_community_roster.py
@@ -3,6 +3,7 @@ import importlib.util
 import json
 import os
 import pathlib
+import shutil
 import subprocess
 import sys
 import tempfile
@@ -112,7 +113,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             roster._validate_webp(raw)
 
     def test_network_response_contracts_are_bounded_and_allowlisted(self):
-        with self.assertRaisesRegex(roster.RosterError, "redirect target"):
+        with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
             roster._read_bounded_response(
                 FakeResponse(b"{}", url="https://evil.example/data";, 
content_type="application/json"),
                 expected_hosts={"whimsy.apache.org"},
@@ -137,13 +138,25 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
                 kind="JSON source",
             )
 
+    def test_redirect_is_rejected_before_following_disallowed_host(self):
+        handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, 
"JSON source")
+        with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
+            handler.redirect_request(
+                mock.Mock(),
+                None,
+                302,
+                "Found",
+                {},
+                "http://127.0.0.1/private";,
+            )
+
     def test_malformed_json_and_encoder_timeout_are_roster_errors(self):
         response = FakeResponse(
             b"{bad",
             url="https://whimsy.apache.org/public/committee-info.json";,
             content_type="application/json",
         )
-        with mock.patch.object(roster.urllib.request, "urlopen", 
return_value=response):
+        with mock.patch.object(roster, "_open_allowlisted", 
return_value=response):
             with self.assertRaisesRegex(roster.RosterError, "malformed JSON"):
                 roster._fetch_json(roster.SOURCES["committee"])
         avatar = FakeResponse(
@@ -151,12 +164,27 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             url="https://avatars.githubusercontent.com/u/1?s=128&v=4";,
             content_type="image/png",
         )
-        with mock.patch.object(roster.urllib.request, "urlopen", 
return_value=avatar), \
+        with mock.patch.object(roster, "_open_allowlisted", 
return_value=avatar), \
              mock.patch.object(roster.shutil, "which", 
return_value="/fake/cwebp"), \
              mock.patch.object(roster.subprocess, "run", 
side_effect=subprocess.TimeoutExpired("cwebp", 20)):
             with self.assertRaisesRegex(roster.RosterError, "cwebp failed"):
                 roster._avatar_bytes(1)
 
+    def test_nested_source_schema_errors_are_roster_errors(self):
+        committee, projects, people, mapping = self.fixture()
+        projects["projects"] = []
+        with self.assertRaisesRegex(roster.RosterError, "projects and 
committees objects"):
+            roster.build_roster(committee, projects, people, mapping)
+        committee, projects, people, mapping = self.fixture()
+        projects["projects"]["hugegraph"]["owners"] = [[]]
+        with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"):
+            roster.build_roster(committee, projects, people, mapping)
+        with tempfile.TemporaryDirectory(prefix="community-json-root-") as 
directory:
+            path = pathlib.Path(directory) / "array.json"
+            path.write_text("[]")
+            with self.assertRaisesRegex(roster.RosterError, "JSON root must be 
an object"):
+                roster._read_json(path)
+
     def test_checked_in_bundle_validates(self):
         self.assertEqual([], roster.validate_bundle(90))
 
@@ -170,6 +198,8 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             map_path.write_text(roster.MAP_PATH.read_text())
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
                  mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
                 with self.assertRaisesRegex(roster.RosterError, "unmapped 
profile URL mismatch"):
                     roster.validate_bundle(90)
@@ -184,6 +214,8 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             map_path.write_text(roster.MAP_PATH.read_text())
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
                  mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
                 with self.assertRaisesRegex(roster.RosterError, "chair must be 
boolean"):
                     roster.validate_bundle(90)
@@ -206,6 +238,8 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
                 map_path.write_text(json.dumps(mapping))
                 with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                      mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", root), \
                      mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
                     with self.assertRaisesRegex(roster.RosterError, "needs a 
local avatar"):
                         roster.validate_bundle(90)
@@ -230,10 +264,50 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             map_path.write_text(json.dumps(mapping))
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
                  mock.patch.object(roster, "AVATAR_DIR", avatar_dir):
                 with self.assertRaisesRegex(roster.RosterError, "must not be a 
symlink"):
                     roster.validate_bundle(90)
 
+    def test_avatar_directory_parent_symlink_is_rejected(self):
+        with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as 
directory:
+            root = pathlib.Path(directory)
+            outside = root / "outside"
+            outside.mkdir()
+            avatar_link = root / "static" / "img" / "community" / "avatars"
+            avatar_link.parent.mkdir(parents=True)
+            avatar_link.symlink_to(outside, target_is_directory=True)
+            with mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root / "data" / 
"community"), \
+                 mock.patch.object(roster, "ROSTER_PATH", root / "data" / 
"community" / "roster.json"), \
+                 mock.patch.object(roster, "MAP_PATH", root / "data" / 
"community" / "github-map.json"), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_link):
+                with self.assertRaisesRegex(roster.RosterError, "symlink path 
components"):
+                    roster._validate_repo_paths()
+
+    def test_member_name_and_initials_must_be_non_empty_and_derived(self):
+        base = json.loads(roster.ROSTER_PATH.read_text())
+        mapping = json.loads(roster.MAP_PATH.read_text())
+        for field, value, message in (
+            ("name", "", "name must be non-empty"),
+            ("initials", "", "initials mismatch"),
+        ):
+            with self.subTest(field=field), 
tempfile.TemporaryDirectory(prefix="community-identity-") as directory:
+                root = pathlib.Path(directory)
+                candidate = json.loads(json.dumps(base))
+                candidate["roles"]["committers"][0][field] = value
+                roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+                roster_path.write_text(json.dumps(candidate))
+                map_path.write_text(json.dumps(mapping))
+                with mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", root), \
+                     mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                     mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                    with self.assertRaisesRegex(roster.RosterError, message):
+                        roster.validate_bundle(90)
+
     def test_validator_rejects_same_name_out_of_asf_id_order(self):
         committee, projects, people, mapping = self.fixture()
         committee["committees"]["hugegraph"]["roster"]["alpha"] = {}
@@ -250,6 +324,8 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             map_path.write_text(json.dumps(mapping))
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
                  mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
                 with self.assertRaisesRegex(roster.RosterError, "sorted by 
public name"):
                     roster.validate_bundle(90)
@@ -274,6 +350,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             candidate = {"roles": {"pmc": [{"avatar": 
"/img/community/avatars/new.webp"}], "committers": []}}
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
                  mock.patch.object(roster, "_validate_avatar_blob"), \
                  mock.patch.object(roster, "_copy_candidate", 
side_effect=OSError("copy failed")):
                 with self.assertRaisesRegex(OSError, "copy failed"):
@@ -291,6 +368,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             with mock.patch.object(roster, "DATA_DIR", root), \
                  mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
                  mock.patch.object(roster, "_fetch_json", return_value={}), \
                  mock.patch.object(roster, "build_roster", 
return_value=candidate), \
                  mock.patch.object(roster, "_install_avatars"), \
@@ -308,6 +386,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             candidate = {"roles": {"pmc": [{"avatar": 
"/img/community/avatars/new.webp"}], "committers": []}}
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
                  mock.patch.object(roster, "_validate_avatar_blob"), \
                  mock.patch.object(roster, "_atomic_write", 
side_effect=OSError("write failed")):
                 with self.assertRaisesRegex(OSError, "write failed"):
@@ -333,6 +412,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
 
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
                  mock.patch.object(roster, "_validate_avatar_blob"), \
                  mock.patch.object(roster, "_unlink", side_effect=fail_once):
                 roster._commit_bundle(candidate, {"new.webp": b"new"})
@@ -353,6 +433,7 @@ print(json.dumps([person["asf_id"] for person in 
result["roles"]["pmc"]]))
             candidate = {"roles": {"pmc": [{"avatar": 
f"/img/community/avatars/{name}"}], "committers": []}}
             with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
                  mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
                  mock.patch.object(roster, "_validate_webp"):
                 roster._commit_bundle(candidate, {name: raw})
             self.assertEqual(raw, destination.read_bytes())
@@ -362,6 +443,11 @@ class CommunityContentContractTests(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         cls._site = 
tempfile.TemporaryDirectory(prefix="community-content-site-")
+        hugo_version = subprocess.check_output(["hugo", "version"], text=True)
+        if "hugo v0.165.0+extended" not in hugo_version:
+            raise RuntimeError(
+                f"Community render contracts require Hugo v0.165.0 Extended: 
{hugo_version.strip()}"
+            )
         environment = {**os.environ, "GOPROXY": "off"}
         subprocess.run(
             ["hugo", "--quiet", "--destination", cls._site.name],
@@ -458,6 +544,8 @@ class CommunityContentContractTests(unittest.TestCase):
             self.assertNotIn("td-page-meta__footer", rendered)
             positions = [rendered.index(marker) for marker in markers]
             self.assertEqual(positions, sorted(positions))
+            member_heading = "项目成员" if relative.startswith("cn/") else 
"Project members"
+            self.assertRegex(rendered, rf"(?m)^## {member_heading}$")
         about = {
             "about/index.md": (
                 "## One ecosystem for graph data and graph intelligence",
@@ -492,6 +580,29 @@ class CommunityContentContractTests(unittest.TestCase):
         )
         self.assertEqual(0, result.returncode, result.stderr)
 
+    def test_artifact_validator_rejects_swapped_role_sections(self):
+        with tempfile.TemporaryDirectory(prefix="community-role-output-") as 
directory:
+            destination = pathlib.Path(directory)
+            for relative in (
+                "community/index.html",
+                "_print/community/index.html",
+                "community/index.md",
+                "cn/community/index.html",
+                "cn/_print/community/index.html",
+                "cn/community/index.md",
+            ):
+                target = destination / relative
+                target.parent.mkdir(parents=True, exist_ok=True)
+                shutil.copyfile(self.site / relative, target)
+            path = destination / "community/index.html"
+            rendered = path.read_text(encoding="utf-8")
+            rendered = rendered.replace('data-community-role="pmc"', 
'data-community-role="temporary"', 1)
+            rendered = rendered.replace('data-community-role="committers"', 
'data-community-role="pmc"', 1)
+            rendered = rendered.replace('data-community-role="temporary"', 
'data-community-role="committers"', 1)
+            path.write_text(rendered, encoding="utf-8")
+            with self.assertRaisesRegex(roster.RosterError, "role order 
drift"):
+                roster.validate_rendered_outputs(destination)
+
     def test_fixed_metadata_is_present_in_actual_offline_indexes(self):
         fixture = json.loads(
             (ROOT / 
"scripts/fixtures/community_search_queries.json").read_text(encoding="utf-8")

Reply via email to