docs validator: enforce source path policy for ouroboros plan links (#357) #360
@@ -51,27 +51,33 @@ def validate_metadata(path: Path, text: str, errors: list[str], doc_ids: dict[st
|
|||||||
doc_ids[doc_id] = path
|
doc_ids[doc_id] = path
|
||||||
|
|
||||||
|
|
||||||
def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> None:
|
def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> bool:
|
||||||
normalized = link.strip()
|
normalized = link.strip()
|
||||||
match = PLAN_LINK_PATTERN.search(normalized)
|
# Ignore in-page anchors and parse the filesystem part for validation.
|
||||||
|
link_path = normalized.split("#", 1)[0].strip()
|
||||||
|
if not link_path:
|
||||||
|
return False
|
||||||
|
match = PLAN_LINK_PATTERN.search(link_path)
|
||||||
if not match:
|
if not match:
|
||||||
return
|
return False
|
||||||
|
|
||||||
version = match.group("version")
|
version = match.group("version")
|
||||||
expected_target = ALLOWED_PLAN_TARGETS[version]
|
expected_target = ALLOWED_PLAN_TARGETS[version]
|
||||||
if normalized.startswith("/"):
|
if link_path.startswith("/"):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{path}: invalid plan link path -> {link} "
|
f"{path}: invalid plan link path -> {link} "
|
||||||
f"(use ./source/ouroboros_plan_v{version}.txt)"
|
f"(use ./source/ouroboros_plan_v{version}.txt)"
|
||||||
)
|
)
|
||||||
return
|
return True
|
||||||
|
|
||||||
resolved_target = (path.parent / normalized).resolve()
|
resolved_target = (path.parent / link_path).resolve()
|
||||||
if resolved_target != expected_target:
|
if resolved_target != expected_target:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{path}: invalid plan link path -> {link} "
|
f"{path}: invalid plan link path -> {link} "
|
||||||
f"(must resolve to docs/ouroboros/source/ouroboros_plan_v{version}.txt)"
|
f"(must resolve to docs/ouroboros/source/ouroboros_plan_v{version}.txt)"
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def validate_links(path: Path, text: str, errors: list[str]) -> None:
|
def validate_links(path: Path, text: str, errors: list[str]) -> None:
|
||||||
@@ -79,11 +85,13 @@ def validate_links(path: Path, text: str, errors: list[str]) -> None:
|
|||||||
link = m.group("link").strip()
|
link = m.group("link").strip()
|
||||||
if not link or link.startswith("http") or link.startswith("#"):
|
if not link or link.startswith("http") or link.startswith("#"):
|
||||||
continue
|
continue
|
||||||
validate_plan_source_link(path, link, errors)
|
if validate_plan_source_link(path, link, errors):
|
||||||
if link.startswith("/"):
|
continue
|
||||||
target = Path(link)
|
link_path = link.split("#", 1)[0].strip()
|
||||||
|
if link_path.startswith("/"):
|
||||||
|
target = Path(link_path)
|
||||||
else:
|
else:
|
||||||
target = (path.parent / link).resolve()
|
target = (path.parent / link_path).resolve()
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
errors.append(f"{path}: broken link -> {link}")
|
errors.append(f"{path}: broken link -> {link}")
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ def test_validate_plan_source_link_accepts_canonical_source_path() -> None:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
path = Path("docs/ouroboros/README.md").resolve()
|
path = Path("docs/ouroboros/README.md").resolve()
|
||||||
|
|
||||||
module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors)
|
assert module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors) is False
|
||||||
module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors)
|
assert module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors) is False
|
||||||
|
|
||||||
assert errors == []
|
assert errors == []
|
||||||
|
|
||||||
@@ -30,12 +30,13 @@ def test_validate_plan_source_link_rejects_root_relative_path() -> None:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
path = Path("docs/ouroboros/README.md").resolve()
|
path = Path("docs/ouroboros/README.md").resolve()
|
||||||
|
|
||||||
module.validate_plan_source_link(
|
handled = module.validate_plan_source_link(
|
||||||
path,
|
path,
|
||||||
"/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt",
|
"/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt",
|
||||||
errors,
|
errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert handled is True
|
||||||
assert errors
|
assert errors
|
||||||
assert "invalid plan link path" in errors[0]
|
assert "invalid plan link path" in errors[0]
|
||||||
assert "use ./source/ouroboros_plan_v2.txt" in errors[0]
|
assert "use ./source/ouroboros_plan_v2.txt" in errors[0]
|
||||||
@@ -46,8 +47,35 @@ def test_validate_plan_source_link_rejects_repo_root_relative_path() -> None:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
path = Path("docs/ouroboros/README.md").resolve()
|
path = Path("docs/ouroboros/README.md").resolve()
|
||||||
|
|
||||||
module.validate_plan_source_link(path, "../../ouroboros_plan_v2.txt", errors)
|
handled = module.validate_plan_source_link(path, "../../ouroboros_plan_v2.txt", errors)
|
||||||
|
|
||||||
|
assert handled is True
|
||||||
assert errors
|
assert errors
|
||||||
assert "invalid plan link path" in errors[0]
|
assert "invalid plan link path" in errors[0]
|
||||||
assert "must resolve to docs/ouroboros/source/ouroboros_plan_v2.txt" in errors[0]
|
assert "must resolve to docs/ouroboros/source/ouroboros_plan_v2.txt" in errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_plan_source_link_accepts_fragment_suffix() -> None:
|
||||||
|
module = _load_module()
|
||||||
|
errors: list[str] = []
|
||||||
|
path = Path("docs/ouroboros/README.md").resolve()
|
||||||
|
|
||||||
|
handled = module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt#sec", errors)
|
||||||
|
|
||||||
|
assert handled is False
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_links_avoids_duplicate_error_for_invalid_plan_link(tmp_path) -> None:
|
||||||
|
module = _load_module()
|
||||||
|
errors: list[str] = []
|
||||||
|
doc = tmp_path / "doc.md"
|
||||||
|
doc.write_text(
|
||||||
|
"[v2](/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
module.validate_links(doc, doc.read_text(encoding="utf-8"), errors)
|
||||||
|
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "invalid plan link path" in errors[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user