Coverage for mcp/metric_readers/localfs.py: 100%
21 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
1"""Confine a caller-supplied path to an allowlisted root directory.
3The local-file metric reader is the only reader that accepts a path into the
4host filesystem, so it needs to prove — before opening anything — that the
5path stays inside a single allowlisted root. This module owns that proof and
6nothing else.
8:func:`resolve_within_root` is a pure function of ``(supplied_path, root)``:
9it resolves the path with realpath semantics (collapsing ``..`` segments and
10following every symlink), then checks the result against the resolved root.
11A single containment test catches both attack classes — a path that walks out
12with ``..`` segments and a path that stays lexically inside but points at a
13symlink whose target lives elsewhere. The helper never opens or reads any
14file, and never consults the environment; the caller reads the configured
15root once and passes it in as ``root``.
16"""
18from __future__ import annotations
20import os
21from pathlib import Path
23from .shape import ErrorCode, MetricReaderError
26def resolve_within_root(supplied_path: str, root: str) -> Path:
27 """Resolve ``supplied_path`` and prove it stays within ``root``.
29 The returned path is the fully resolved location to read from, guaranteed
30 to live inside the resolved ``root``. Resolution uses realpath semantics:
31 ``..`` segments are collapsed and every symlink in the path is followed,
32 so a path escapes by either route is caught by the same containment check.
34 ``root`` must be a non-empty directory path; the caller is responsible for
35 reading it (for example from an environment variable) before calling here.
36 An unset or empty ``root`` raises a not-configured error so the reader can
37 refuse rather than fall back to some implicit location.
39 Raises:
40 MetricReaderError: with code ``LOCAL_ROOT_NOT_CONFIGURED`` when
41 ``root`` is empty; ``PATH_TRAVERSAL_ESCAPE`` when the supplied
42 path's ``..`` segments alone walk outside the root; or
43 ``SYMLINK_ESCAPE`` when the path stays lexically inside the root
44 but a symlink target points outside it. The supplied path is
45 included in the error details for the two escape cases.
46 """
47 if not root:
48 raise MetricReaderError(ErrorCode.LOCAL_ROOT_NOT_CONFIGURED)
50 # Absolute, real root: collapses ``..`` and follows any symlinks so the
51 # containment comparison below is between two fully-resolved paths.
52 real_root = Path(root).resolve()
54 supplied = Path(supplied_path)
55 # An absolute supplied path is used as-is; a relative one is joined under
56 # the root before resolution.
57 candidate = supplied if supplied.is_absolute() else (real_root / supplied)
58 resolved = candidate.resolve()
60 if not resolved.is_relative_to(real_root):
61 # The path escaped. Decide which distinct code to surface by asking
62 # whether ``..`` segments alone — collapsed lexically, without
63 # following any symlink — would already leave the root. If they would,
64 # this is a traversal escape; otherwise the lexical path stayed inside
65 # and only a symlink target jumped out.
66 if _lexically_escapes(supplied, real_root):
67 raise MetricReaderError(
68 ErrorCode.PATH_TRAVERSAL_ESCAPE,
69 {"supplied_path": supplied_path},
70 )
71 raise MetricReaderError(
72 ErrorCode.SYMLINK_ESCAPE,
73 {"supplied_path": supplied_path},
74 )
76 return resolved
79def _lexically_escapes(supplied: Path, real_root: Path) -> bool:
80 """Return True when collapsing ``..`` alone walks ``supplied`` out of root.
82 This mirrors the realpath collapse but stops short of touching the
83 filesystem: ``os.path.normpath`` folds ``..`` segments purely lexically
84 (it never follows symlinks). The result tells the caller whether an escape
85 was caused by the path text itself rather than by a symlink target.
86 """
87 if supplied.is_absolute():
88 lexical = Path(os.path.normpath(supplied))
89 else:
90 lexical = Path(os.path.normpath(real_root / supplied))
91 return not lexical.is_relative_to(real_root)