feat(okf): path/reserved-name validation gate — traversal + index.md/log.md shadow (T4, TDD, +10)
This commit is contained in:
parent
f8bc5db547
commit
ec121f3259
2 changed files with 104 additions and 0 deletions
|
|
@ -30,8 +30,10 @@ from .report import Report, Source
|
|||
__all__ = [
|
||||
"parse_frontmatter",
|
||||
"scan_concept",
|
||||
"validate_concept_path",
|
||||
"OKFError",
|
||||
"OKFFrontmatterError",
|
||||
"OKFPathError",
|
||||
]
|
||||
|
||||
_FENCE = "---"
|
||||
|
|
@ -52,6 +54,15 @@ class OKFFrontmatterError(OKFError):
|
|||
"""Frontmatter violates the strict, reject-by-default OKF subset."""
|
||||
|
||||
|
||||
class OKFPathError(OKFError):
|
||||
"""A concept path is unsafe (traversal, absolute, or reserved-name shadow)."""
|
||||
|
||||
|
||||
# `index.md` (directory listing) and `log.md` (update history) are reserved by
|
||||
# the OKF spec and MUST NOT name concept documents — at any directory level.
|
||||
_RESERVED_BASENAMES = frozenset({"index.md", "log.md"})
|
||||
|
||||
|
||||
def parse_frontmatter(document):
|
||||
"""Split leading OKF frontmatter from the body and parse it strictly.
|
||||
|
||||
|
|
@ -110,6 +121,44 @@ def _scannable_regions(frontmatter, body):
|
|||
return regions
|
||||
|
||||
|
||||
def validate_concept_path(path):
|
||||
"""Validate a bundle-relative concept path and return its concept-ID.
|
||||
|
||||
T4 — path / reserved-name gate. The concept-ID is the path with the ``.md``
|
||||
suffix removed (OKF spec). Rejects, before the path is ever used to write:
|
||||
|
||||
- ``..`` traversal at any segment (escape the bundle);
|
||||
- absolute paths (``/...``) and backslashes (platform-separator ambiguity);
|
||||
- the reserved basenames ``index.md`` / ``log.md`` (shadow the directory
|
||||
listing / update log), case-insensitively — a case-insensitive filesystem
|
||||
lets ``Index.md`` shadow ``index.md``;
|
||||
- non-``.md`` files (not a concept document).
|
||||
|
||||
Raises :class:`OKFPathError` on any of these; returns the concept-ID string.
|
||||
"""
|
||||
if not path or not isinstance(path, str):
|
||||
raise OKFPathError("empty or non-string concept path: %r" % (path,))
|
||||
if path.startswith("/"):
|
||||
raise OKFPathError("concept path must be bundle-relative, not absolute: %r" % path)
|
||||
if "\\" in path:
|
||||
raise OKFPathError("backslashes are not permitted in a concept path: %r" % path)
|
||||
|
||||
segments = path.split("/")
|
||||
for seg in segments:
|
||||
if seg == "..":
|
||||
raise OKFPathError("path traversal ('..') is not permitted: %r" % path)
|
||||
if seg == "" or seg == ".":
|
||||
raise OKFPathError("malformed path segment in %r" % path)
|
||||
|
||||
basename = segments[-1]
|
||||
if basename.lower() in _RESERVED_BASENAMES:
|
||||
raise OKFPathError("reserved filename may not name a concept: %r" % basename)
|
||||
if not basename.lower().endswith(".md"):
|
||||
raise OKFPathError("a concept document must be a .md file: %r" % path)
|
||||
|
||||
return path[: -len(".md")]
|
||||
|
||||
|
||||
def _parse_flat(fm_lines):
|
||||
result = {}
|
||||
i = 0
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ import pytest
|
|||
from llm_ingestion_guard.okf import (
|
||||
parse_frontmatter,
|
||||
scan_concept,
|
||||
validate_concept_path,
|
||||
OKFFrontmatterError,
|
||||
OKFPathError,
|
||||
)
|
||||
from llm_ingestion_guard.report import Report
|
||||
|
||||
|
|
@ -145,3 +147,56 @@ def test_scan_concept_clean_concept_is_clean():
|
|||
"tags:\n - pii\n---\nA clean paragraph describing the users table.\n"
|
||||
)
|
||||
assert scan_concept(doc).found is False
|
||||
|
||||
|
||||
# --- T4: path / reserved-name validation -------------------------------------
|
||||
# OKF spec (verified 2026-07-06): concept-ID = file path minus `.md`;
|
||||
# `index.md` and `log.md` are reserved and MUST NOT name concept documents.
|
||||
|
||||
def test_validate_concept_path_returns_concept_id():
|
||||
assert validate_concept_path("tables/users.md") == "tables/users"
|
||||
|
||||
|
||||
def test_validate_concept_path_accepts_deeply_nested():
|
||||
assert validate_concept_path("a/b/c/d.md") == "a/b/c/d"
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_leading_traversal():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("../etc/passwd.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_embedded_traversal():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("tables/../../secret.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_absolute():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("/etc/passwd.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_reserved_index():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("index.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_reserved_log_at_any_level():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("tables/log.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_reserved_case_insensitively():
|
||||
# a case-insensitive filesystem lets Index.md shadow index.md
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("Index.MD")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_backslash():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("tables\\users.md")
|
||||
|
||||
|
||||
def test_validate_concept_path_rejects_non_md():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("tables/users.txt")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue