diff --git a/okf/src/reference_agent/bundle/paths.py b/okf/src/reference_agent/bundle/paths.py index 5f6b7c36..1b0e48c5 100644 --- a/okf/src/reference_agent/bundle/paths.py +++ b/okf/src/reference_agent/bundle/paths.py @@ -2,6 +2,7 @@ import re from pathlib import Path +from urllib.parse import urlparse _SEGMENT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*") @@ -32,3 +33,47 @@ def parse_concept_id(s: str) -> tuple[str, ...]: for p in parts: _validate_segment(p) return parts + + +def resolve_reference_path( + bundle_root: Path, + concept_path: Path, + reference: str, +) -> str: + """Resolve an OKF ยง6.2 path-valued reference. + + URLs are returned unchanged. + + Bundle-relative paths beginning with '/' are interpreted relative + to the bundle root. + + Other paths are interpreted relative to the directory containing + the concept document. + + The returned bundle-relative paths always begin with '/'. + """ + reference = str(reference).strip() + + if not reference: + return reference + + parsed = urlparse(reference) + if parsed.scheme: + return reference + + bundle_root = bundle_root.resolve() + concept_path = concept_path.resolve() + + if reference.startswith("/"): + candidate = (bundle_root / reference.lstrip("/")).resolve() + else: + candidate = (concept_path.parent / reference).resolve() + + try: + relative = candidate.relative_to(bundle_root) + except ValueError as exc: + raise ValueError( + f"Reference escapes bundle root: {reference!r}" + ) from exc + + return "/" + relative.as_posix() diff --git a/okf/tests/test_paths.py b/okf/tests/test_paths.py new file mode 100644 index 00000000..3399b5b3 --- /dev/null +++ b/okf/tests/test_paths.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from reference_agent.bundle.paths import ( + concept_id_to_path, + parse_concept_id, + path_to_concept_id, + resolve_reference_path, +) + + +def test_concept_id_to_path(): + root = Path("/bundle") + assert concept_id_to_path(root, ("tables", "orders")) == Path( + "/bundle/tables/orders.md" + ) + + +def test_path_to_concept_id(): + root = Path("/bundle") + path = Path("/bundle/tables/orders.md") + assert path_to_concept_id(root, path) == ("tables", "orders") + + +def test_parse_concept_id(): + assert parse_concept_id("tables/orders") == ("tables", "orders") + assert parse_concept_id("/tables/orders/") == ("tables", "orders") + + +def test_resolve_absolute_url_unchanged(): + root = Path("/bundle") + concept = Path("/bundle/computations/revenue-ytd.md") + + assert resolve_reference_path( + root, concept, "https://example.com/revenue" + ) == "https://example.com/revenue" + + +def test_resolve_bundle_relative_path(): + root = Path("/bundle") + concept = Path("/bundle/computations/revenue-ytd.md") + + assert resolve_reference_path( + root, concept, "/tables/orders.md" + ) == "/tables/orders.md" + + +def test_resolve_relative_path_from_concept_directory(): + root = Path("/bundle") + concept = Path("/bundle/computations/revenue-ytd.md") + + assert resolve_reference_path( + root, concept, "../tables/orders.md" + ) == "/tables/orders.md" + + +def test_resolve_nested_relative_path(): + root = Path("/bundle") + concept = Path("/bundle/computations/revenue-ytd.md") + + assert resolve_reference_path( + root, concept, "skills/run-on-bq.md" + ) == "/computations/skills/run-on-bq.md" + + +def test_resolve_relative_path_cannot_escape_bundle(): + root = Path("/bundle") + concept = Path("/bundle/computations/revenue-ytd.md") + + with pytest.raises(ValueError): + resolve_reference_path(root, concept, "../../outside.md")