A received OKF bundle MAY legitimately carry index.md (directory listing, read first under progressive disclosure) and log.md (update history) at any level (spec §3.1/§6/§7). import_bundle previously hard-rejected those basenames in the T4 path gate, so a conformant third-party bundle was over-blocked in full (FAIL_SECURE) — and because the reject fired before scan_concept, index.md's body (the highest-priority injection surface) was never scanned. import_bundle now defaults allow_reserved=True: reserved basenames are scanned as structural files (path-safety checks — traversal / absolute / backslash / .md — still apply). The shadow-reject (an *upload* masquerading as index.md) is preserved: the front-end passes allow_reserved=False so a materialized upload landing on a reserved basename is still refused. That front-end opt-in was required to keep the shadow-reject once the default flipped (not in the plan's Filer set; traced from the code). - okf.py: validate_concept_path/_validate_concept/import_bundle gain the keyword; validate_concept_path default stays False (strict standalone). - tests: +3 (legit index/log admit; injection in index.md body caught; okf_version frontmatter admits). Per-concept-iteration test switched to a traversal vector; mode-b showcase's index.md surface reframed from reserved-name-reject to index.md-body-scan. - README honest-limits + CLAUDE.md context note the mode-b/upload distinction. Suite: 341 -> 344 passed. Core invariant intact (dependencies=[]).
595 lines
22 KiB
Python
595 lines
22 KiB
Python
"""OKF adapter — Open Knowledge Format (Google, v0.1) support on top of the core.
|
|
|
|
Design principle: the format-agnostic core stays ``text -> findings``. This
|
|
adapter knows OKF structure (frontmatter, paths, links, ``resource``, bundles)
|
|
and feeds scannable text regions into the existing ``sanitize`` / ``scan_output``
|
|
/ ``disposition`` machinery. No YAML/format awareness leaks into the core.
|
|
|
|
T2 — frontmatter parse-safety gate. ``parse_frontmatter`` is a *strict,
|
|
reject-by-default* loader for the minimal OKF frontmatter subset: flat
|
|
``key: value`` scalars plus block ``- item`` lists. Every construct the
|
|
"block anchor/alias DoS + dangerous type coercion" requirement names is refused
|
|
*by construction* — you cannot suffer a billion-laughs alias expansion or a
|
|
``!!python/object`` coercion if anchors, aliases and explicit tags are rejected
|
|
before any value is interpreted. This is the "reject, don't parse-then-sanitize"
|
|
philosophy, the frontmatter analogue of the ``resource`` reject-gate (T3).
|
|
|
|
Deliberately NOT a general YAML parser. A security tool whose thesis is
|
|
minimal-dependency should not pull in a full YAML engine whose own features
|
|
(anchors, tags, merges) are the attack surface being defended against. Quoted
|
|
scalars are kept verbatim (quotes included) rather than unquoted — the value is
|
|
still scanned as text downstream, so an injection inside a quoted value is not
|
|
lost; richer scalar forms are a future refinement, not a silent parse.
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from .output import scan_output
|
|
from .report import Report, Source
|
|
from .disposition import Trust, Disposition, Policy, decide
|
|
|
|
__all__ = [
|
|
"parse_frontmatter",
|
|
"scan_concept",
|
|
"validate_concept_path",
|
|
"validate_resource_url",
|
|
"trust_for",
|
|
"stamp_concept",
|
|
"format_log_entry",
|
|
"import_bundle",
|
|
"extract_link_targets",
|
|
"resolve_link",
|
|
"link_graph",
|
|
"Origin",
|
|
"Channel",
|
|
"ProvenanceStamp",
|
|
"ConceptResult",
|
|
"BundleResult",
|
|
"LinkGraphResult",
|
|
"OKFError",
|
|
"OKFFrontmatterError",
|
|
"OKFPathError",
|
|
"OKFResourceError",
|
|
"OKFLinkError",
|
|
]
|
|
|
|
_FENCE = "---"
|
|
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
|
|
|
# A plain OKF scalar cannot *begin* with a YAML structural indicator. Any value
|
|
# starting with one signals an anchor (&), alias (*), explicit tag (!), block
|
|
# scalar (|, >), flow collection ([ ] { }), directive (%) or reserved char
|
|
# (@ `) — all outside the supported subset and all rejected.
|
|
_DANGEROUS_VALUE_STARTS = frozenset("&*!|>[]{}%@`")
|
|
|
|
|
|
class OKFError(Exception):
|
|
"""Base class for OKF adapter rejections."""
|
|
|
|
|
|
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)."""
|
|
|
|
|
|
class OKFResourceError(OKFError):
|
|
"""A ``resource`` URL is not on the https allowlist."""
|
|
|
|
|
|
class OKFLinkError(OKFError):
|
|
"""A cross-link target is unsafe (dangerous scheme or bundle escape)."""
|
|
|
|
|
|
_URL_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
|
|
|
|
|
|
# `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.
|
|
|
|
Returns ``(frontmatter: dict, body: str)``. A document with no leading
|
|
``---`` fence has no frontmatter: ``({}, document)`` is returned unchanged.
|
|
|
|
Raises ``OKFFrontmatterError`` on an unterminated fence or any construct
|
|
outside the minimal flat subset (anchors, aliases, explicit tags, merge
|
|
keys, block scalars, flow collections, nested mappings).
|
|
"""
|
|
lines = document.split("\n")
|
|
if not lines or lines[0].strip() != _FENCE:
|
|
return {}, document
|
|
|
|
close_idx = None
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == _FENCE:
|
|
close_idx = i
|
|
break
|
|
if close_idx is None:
|
|
raise OKFFrontmatterError("unterminated frontmatter: no closing '---' fence")
|
|
|
|
frontmatter = _parse_flat(lines[1:close_idx])
|
|
body = "\n".join(lines[close_idx + 1:])
|
|
return frontmatter, body
|
|
|
|
|
|
def scan_concept(document, *, source=Source.OUTPUT):
|
|
"""Scan every scannable region of one OKF concept, merged into one Report.
|
|
|
|
T1 — whole-concept scan surface. The body is not the only injectable region:
|
|
OKF frontmatter *values* (notably ``description``, which propagates into
|
|
``index.md`` and is read first under progressive disclosure), ``tags`` items
|
|
and the ``resource`` string are all attacker-controlled and must go through
|
|
the same ``scan_output`` path as the body. Findings from all regions are
|
|
merged so nothing in the frontmatter escapes the gate.
|
|
|
|
Frontmatter is parsed with the strict :func:`parse_frontmatter` gate first,
|
|
so a parse-safety violation (T2) raises before any scanning.
|
|
"""
|
|
frontmatter, body = parse_frontmatter(document)
|
|
report = Report()
|
|
for region in _scannable_regions(frontmatter, body):
|
|
report.extend(scan_output(region, source=source).findings)
|
|
return report
|
|
|
|
|
|
def _scannable_regions(frontmatter, body):
|
|
"""The text regions of a concept that carry attacker-controlled content."""
|
|
regions = [body]
|
|
for value in frontmatter.values():
|
|
if isinstance(value, list):
|
|
regions.extend(value)
|
|
elif value:
|
|
regions.append(value)
|
|
return regions
|
|
|
|
|
|
def validate_concept_path(path, *, allow_reserved=False):
|
|
"""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).
|
|
|
|
``allow_reserved`` (default ``False``) keeps this a strict concept-path
|
|
validator: a reserved basename is not a concept and is rejected. A mode-b
|
|
bundle import passes ``allow_reserved=True`` because a *received* bundle MAY
|
|
legitimately carry ``index.md`` / ``log.md`` as structural files — the caller
|
|
then scans their body rather than persisting them as concepts. The path-safety
|
|
checks (traversal / absolute / backslash / ``.md``) still apply either way.
|
|
|
|
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 not allow_reserved and 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 validate_resource_url(url):
|
|
"""Validate a concept's ``resource`` URL against the https allowlist (T3).
|
|
|
|
The OKF format places no constraint on the ``resource`` scheme (verified
|
|
against SPEC.md), so this default-deny allowlist is the only gate: it
|
|
**rejects** anything that is not ``https`` — ``http``, ``data:``,
|
|
``javascript:``, ``file:``, ``blob:``, ``ftp:`` and schemeless/relative
|
|
strings — *before commit*. This is reject, not defang: ``neutralize`` renders
|
|
dangerous schemes inert for human audit; this refuses to persist them at all.
|
|
|
|
Returns ``url`` unchanged on success; raises :class:`OKFResourceError`
|
|
otherwise.
|
|
"""
|
|
if not url or not isinstance(url, str):
|
|
raise OKFResourceError("empty or non-string resource URL: %r" % (url,))
|
|
stripped = url.strip()
|
|
if " " in stripped or any(ord(c) < 0x20 for c in stripped):
|
|
raise OKFResourceError("resource URL contains whitespace/control chars: %r" % url)
|
|
match = _URL_SCHEME_RE.match(stripped)
|
|
scheme = match.group(1).lower() if match else None
|
|
if scheme != "https":
|
|
raise OKFResourceError(
|
|
"resource URL must use the https scheme (got %r): %r" % (scheme, url)
|
|
)
|
|
return url
|
|
|
|
|
|
class Origin(str, Enum):
|
|
"""Where the data actually came from (brief §5) — drives trust."""
|
|
|
|
EXTERNAL = "external"
|
|
INTERNAL = "internal"
|
|
|
|
|
|
class Channel(str, Enum):
|
|
"""How it was inserted — recorded for the log, but never upgrades trust."""
|
|
|
|
AUTOMATIC = "automatic"
|
|
MANUAL = "manual"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvenanceStamp:
|
|
"""A per-concept provenance record for ``log.md`` (brief §6 T6).
|
|
|
|
Composes ``Origin`` x ``Channel`` x ``Trust`` x ``Disposition`` — it adds no
|
|
new disposition value (brief §8 naming caveat); the disposition is whatever
|
|
:func:`decide` returns for the concept's scan under its origin-derived trust.
|
|
"""
|
|
|
|
concept_id: str
|
|
origin: Origin
|
|
channel: Channel
|
|
trust: Trust
|
|
disposition: Disposition
|
|
|
|
|
|
def trust_for(origin, channel=None):
|
|
"""Map a concept's origin to a :class:`Trust` tier (brief §5).
|
|
|
|
Trust follows the *origin*, never the insertion *channel*: a manual paste of
|
|
external material is still external. The channel is recorded on the stamp for
|
|
the audit log but grants no trust discount.
|
|
"""
|
|
return Trust.TRUSTED if origin is Origin.INTERNAL else Trust.UNTRUSTED
|
|
|
|
|
|
def stamp_concept(concept_id, report, origin, channel):
|
|
"""Stamp one scanned concept with its provenance and disposition (T6).
|
|
|
|
``report`` is the concept's scan (e.g. from :func:`scan_concept`); the
|
|
disposition is decided under a policy at the origin-derived trust tier.
|
|
"""
|
|
trust = trust_for(origin, channel)
|
|
decision = decide(report, Policy(trust=trust))
|
|
return ProvenanceStamp(concept_id, origin, channel, trust, decision.disposition)
|
|
|
|
|
|
def format_log_entry(stamp, *, timestamp=None):
|
|
"""Render a :class:`ProvenanceStamp` as one tab-separated ``log.md`` line.
|
|
|
|
``timestamp`` is caller-supplied (kept out of the stamp so stamping stays
|
|
deterministic and wall-clock-free); when given it is prepended.
|
|
"""
|
|
fields = [
|
|
stamp.concept_id,
|
|
stamp.origin.value,
|
|
stamp.channel.value,
|
|
stamp.trust.value,
|
|
stamp.disposition.value,
|
|
]
|
|
if timestamp is not None:
|
|
fields.insert(0, timestamp)
|
|
return "\t".join(fields)
|
|
|
|
|
|
# --- T7: bundle-import iterator (mode b) -------------------------------------
|
|
|
|
# WARN < QUARANTINE_REVIEW < FAIL_SECURE — the aggregate is the most severe.
|
|
_DISPOSITION_ORDER = (
|
|
Disposition.WARN,
|
|
Disposition.QUARANTINE_REVIEW,
|
|
Disposition.FAIL_SECURE,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConceptResult:
|
|
"""The outcome of validating one concept in a bundle.
|
|
|
|
``error`` is ``None`` on a concept that passed the gates (and then carries a
|
|
``stamp``); a non-``None`` ``error`` means a hard reject (bad path, unsafe
|
|
frontmatter, or a non-https ``resource``) — ``disposition`` is FAIL_SECURE
|
|
and no stamp is produced, so the concept must not be merged.
|
|
"""
|
|
|
|
path: str
|
|
concept_id: str | None
|
|
disposition: Disposition
|
|
stamp: ProvenanceStamp | None
|
|
report: Report
|
|
error: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundleResult:
|
|
"""Per-concept results, the aggregate disposition, and the cross-link graph.
|
|
|
|
``links`` is the in-import :class:`LinkGraphResult` for the whole bundle
|
|
(dangling / rejected / resolved edges), so a mode-b import returns both halves
|
|
of the gate together. Whether a dangling or rejected link should block is the
|
|
caller's disposition call (design principle 4).
|
|
"""
|
|
|
|
concepts: tuple
|
|
disposition: Disposition
|
|
links: "LinkGraphResult"
|
|
|
|
def log(self):
|
|
"""The ``log.md`` body — one line per concept, rejected ones marked."""
|
|
lines = []
|
|
for c in self.concepts:
|
|
if c.stamp is not None:
|
|
lines.append(format_log_entry(c.stamp))
|
|
else:
|
|
lines.append("\t".join([c.path, "REJECTED", c.disposition.value, c.error or ""]))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC, allow_reserved=True):
|
|
"""Validate a received OKF bundle concept-by-concept before merge (mode b).
|
|
|
|
``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document
|
|
text. Each concept runs the full per-concept gate — path/reserved-name (T4),
|
|
frontmatter parse-safety (T2), ``resource`` allowlist (T3), whole-concept
|
|
scan (T1) and provenance stamping (T6). A concept that fails a hard gate is
|
|
rejected (FAIL_SECURE) and recorded, but iteration continues, so the caller
|
|
sees every issue in the bundle, not only the first. The bundle disposition is
|
|
the most severe across its concepts.
|
|
|
|
``allow_reserved`` (default ``True``) reflects that this is the mode-b
|
|
*received-bundle* path: ``index.md`` / ``log.md`` are legitimate structural
|
|
files (OKF spec §3.1/§6/§7) that MAY appear at any level, so they are scanned
|
|
(their body is the highest-priority injection surface) rather than
|
|
path-rejected — over-blocking a conformant third-party bundle is itself a
|
|
failure mode (brief principle 5). A front-end materialising individual
|
|
*uploads* passes ``allow_reserved=False``: there a reserved basename is a
|
|
shadow of the directory listing and must be refused.
|
|
"""
|
|
results = tuple(
|
|
_validate_concept(path, bundle[path], origin, channel, allow_reserved=allow_reserved)
|
|
for path in sorted(bundle)
|
|
)
|
|
aggregate = _most_severe(r.disposition for r in results)
|
|
return BundleResult(results, aggregate, link_graph(bundle))
|
|
|
|
|
|
def _validate_concept(path, doc, origin, channel, *, allow_reserved=True):
|
|
try:
|
|
concept_id = validate_concept_path(path, allow_reserved=allow_reserved)
|
|
except OKFPathError as exc:
|
|
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
try:
|
|
frontmatter, _body = parse_frontmatter(doc)
|
|
except OKFFrontmatterError as exc:
|
|
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
resource = frontmatter.get("resource")
|
|
if isinstance(resource, str):
|
|
try:
|
|
validate_resource_url(resource)
|
|
except OKFResourceError as exc:
|
|
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
|
report = scan_concept(doc)
|
|
stamp = stamp_concept(concept_id, report, origin, channel)
|
|
return ConceptResult(path, concept_id, stamp.disposition, stamp, report, None)
|
|
|
|
|
|
def _most_severe(dispositions):
|
|
worst = Disposition.WARN
|
|
for disposition in dispositions:
|
|
if _DISPOSITION_ORDER.index(disposition) > _DISPOSITION_ORDER.index(worst):
|
|
worst = disposition
|
|
return worst
|
|
|
|
|
|
# --- T5a / A: cross-link graph (in-import) -----------------------------------
|
|
# The persisted cross-run graph (B) that would catch "plant a link now, write
|
|
# the poisoned target in a LATER run" (§7.2) is deferred to stream 2, where the
|
|
# consumer that owns the corpus decides where the durable graph state lives.
|
|
# This in-import graph resolves links within a single bundle merge.
|
|
|
|
_MD_LINK_RE = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)")
|
|
# Active-content schemes are refused in a link, mirroring the resource gate (T3).
|
|
_DANGEROUS_LINK_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "blob"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkGraphResult:
|
|
"""In-import cross-link resolution over one bundle.
|
|
|
|
``dangling`` — ``(from_id, target_concept_id)`` for in-bundle ``.md`` links
|
|
whose target concept is **not present** in the bundle: the dormant-injection
|
|
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
|
|
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
|
|
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
|
|
concepts present in the bundle.
|
|
"""
|
|
|
|
dangling: tuple
|
|
rejected: tuple
|
|
resolved: tuple
|
|
|
|
|
|
def extract_link_targets(body):
|
|
"""Return the destinations of markdown ``[text](target)`` links in ``body``."""
|
|
return _MD_LINK_RE.findall(body)
|
|
|
|
|
|
def resolve_link(target, from_concept_id):
|
|
"""Resolve one link target to an in-bundle concept-ID, or reject it (T5a).
|
|
|
|
Returns the target concept-ID for an in-bundle ``.md`` link (bundle-absolute
|
|
``/x.md`` or relative ``./x.md`` / ``../y.md``, resolved against the linking
|
|
concept's directory). Returns ``None`` for an external ``http(s)``/other
|
|
non-active link (not a concept edge) and for non-``.md`` targets. Raises
|
|
:class:`OKFLinkError` for an active-content scheme or a ``..`` escape past the
|
|
bundle root.
|
|
"""
|
|
candidate = target.strip().split("#", 1)[0].split("?", 1)[0]
|
|
if not candidate:
|
|
return None
|
|
|
|
scheme_match = _URL_SCHEME_RE.match(candidate)
|
|
if scheme_match:
|
|
scheme = scheme_match.group(1).lower()
|
|
if scheme in _DANGEROUS_LINK_SCHEMES:
|
|
raise OKFLinkError("link uses a dangerous scheme %r: %r" % (scheme, target))
|
|
return None # external (http/https/mailto/…): not an in-bundle concept edge
|
|
|
|
if not candidate.endswith(".md"):
|
|
return None # not a concept-document link (asset, anchor, …)
|
|
|
|
if candidate.startswith("/"):
|
|
normalized = _normalize_bundle_path(candidate[1:])
|
|
else:
|
|
from_dir = from_concept_id.rsplit("/", 1)[0] if "/" in from_concept_id else ""
|
|
joined = from_dir + "/" + candidate if from_dir else candidate
|
|
normalized = _normalize_bundle_path(joined)
|
|
|
|
return normalized[: -len(".md")]
|
|
|
|
|
|
def link_graph(bundle):
|
|
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
|
|
|
|
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
|
|
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
|
|
"""
|
|
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
|
|
dangling, rejected, resolved = [], [], []
|
|
|
|
for path in sorted(bundle):
|
|
if not path.endswith(".md"):
|
|
continue
|
|
from_id = path[: -len(".md")]
|
|
try:
|
|
_frontmatter, body = parse_frontmatter(bundle[path])
|
|
except OKFFrontmatterError:
|
|
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
|
|
|
|
for target in extract_link_targets(body):
|
|
try:
|
|
concept_id = resolve_link(target, from_id)
|
|
except OKFLinkError as exc:
|
|
rejected.append((from_id, target, str(exc)))
|
|
continue
|
|
if concept_id is None:
|
|
continue
|
|
if concept_id in present:
|
|
resolved.append((from_id, concept_id))
|
|
else:
|
|
dangling.append((from_id, concept_id))
|
|
|
|
return LinkGraphResult(tuple(dangling), tuple(rejected), tuple(resolved))
|
|
|
|
|
|
def _normalize_bundle_path(path):
|
|
"""Normalize a ``/``-separated bundle path; raise if it escapes the root."""
|
|
parts = []
|
|
for segment in path.split("/"):
|
|
if segment in ("", "."):
|
|
continue
|
|
if segment == "..":
|
|
if not parts:
|
|
raise OKFLinkError("link target escapes the bundle root: %r" % path)
|
|
parts.pop()
|
|
else:
|
|
parts.append(segment)
|
|
return "/".join(parts)
|
|
|
|
|
|
def _parse_flat(fm_lines):
|
|
result = {}
|
|
i = 0
|
|
n = len(fm_lines)
|
|
while i < n:
|
|
raw = fm_lines[i]
|
|
stripped = raw.strip()
|
|
|
|
if stripped == "" or stripped.startswith("#"):
|
|
i += 1
|
|
continue
|
|
|
|
# An indented line with no active list key is a nested structure.
|
|
if raw[:1] in (" ", "\t"):
|
|
raise OKFFrontmatterError(
|
|
"nested mappings are not supported in OKF frontmatter: %r" % raw
|
|
)
|
|
|
|
if stripped.startswith("<<"):
|
|
raise OKFFrontmatterError("YAML merge keys are not permitted")
|
|
|
|
if ":" not in stripped:
|
|
raise OKFFrontmatterError("malformed frontmatter line: %r" % raw)
|
|
|
|
key, _, value = stripped.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if not _KEY_RE.match(key):
|
|
raise OKFFrontmatterError("invalid frontmatter key: %r" % key)
|
|
|
|
if value == "":
|
|
items, i = _consume_block_list(fm_lines, i + 1)
|
|
result[key] = items if items is not None else ""
|
|
continue
|
|
|
|
_reject_dangerous_value(value)
|
|
result[key] = value
|
|
i += 1
|
|
|
|
return result
|
|
|
|
|
|
def _consume_block_list(fm_lines, start):
|
|
"""Consume `` - item`` lines following a bare ``key:``.
|
|
|
|
Returns ``(items, next_index)`` — ``items`` is ``None`` (and ``next_index``
|
|
unchanged) when no list item follows, so the caller can treat the key as an
|
|
empty scalar and let the next line trip the nested-structure guard.
|
|
"""
|
|
items = []
|
|
i = start
|
|
n = len(fm_lines)
|
|
while i < n:
|
|
raw = fm_lines[i]
|
|
stripped = raw.strip()
|
|
if stripped == "" or stripped.startswith("#"):
|
|
i += 1
|
|
continue
|
|
if raw[:1] in (" ", "\t") and stripped.startswith("- "):
|
|
item = stripped[2:].strip()
|
|
_reject_dangerous_value(item)
|
|
items.append(item)
|
|
i += 1
|
|
continue
|
|
break
|
|
if not items:
|
|
return None, start
|
|
return items, i
|
|
|
|
|
|
def _reject_dangerous_value(value):
|
|
if value and value[0] in _DANGEROUS_VALUE_STARTS:
|
|
raise OKFFrontmatterError(
|
|
"value begins with a disallowed YAML indicator %r: %r"
|
|
% (value[0], value)
|
|
)
|