paths.py
python
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142
docs: add muse-tag-guide mist (complete idiomatic guide wit…
Sonnet 4.6
19 days ago
| 1 | """Harmony path helpers and path-level validation. |
| 2 | |
| 3 | Single responsibility: compute filesystem paths for harmony storage and |
| 4 | validate ID strings before they touch the filesystem. No I/O beyond |
| 5 | path construction. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import pathlib |
| 11 | import re |
| 12 | |
| 13 | from muse.core.paths import harmony_dir as _harmony_dir |
| 14 | from muse.core.types import split_id |
| 15 | |
| 16 | # --------------------------------------------------------------------------- |
| 17 | # String constants — harmony storage layout |
| 18 | # --------------------------------------------------------------------------- |
| 19 | |
| 20 | _HARMONY = "harmony" |
| 21 | _PATTERNS = "patterns" |
| 22 | _POLICIES = "policies" |
| 23 | _AUDIT = "audit" |
| 24 | _ESCALATIONS = "escalations" |
| 25 | _RESOLUTIONS = "resolutions" |
| 26 | _PATTERN_FILE = "pattern.json" |
| 27 | |
| 28 | # --------------------------------------------------------------------------- |
| 29 | # Size and format constants used by validation |
| 30 | # --------------------------------------------------------------------------- |
| 31 | |
| 32 | #: Maximum bytes for a semantic fingerprint string. |
| 33 | #: Fingerprints may be rich token-bag strings (code plugin) rather than hex64. |
| 34 | _MAX_FINGERPRINT_BYTES: int = 4_096 # 4 KiB |
| 35 | |
| 36 | #: Regex matching a canonical content-addressed ID: ``sha256:`` + 64 lowercase hex chars. |
| 37 | _SHA256_ID_RE: re.Pattern[str] = re.compile(r"^sha256:[0-9a-f]{64}$") |
| 38 | |
| 39 | #: Regex matching a bare 64-char lowercase hex string (used for filesystem dir names). |
| 40 | _BARE_HEX64_RE: re.Pattern[str] = re.compile(r"^[0-9a-f]{64}$") |
| 41 | |
| 42 | #: Regex matching a valid policy ID: alphanumeric + hyphen + underscore, 1–128 chars. |
| 43 | _POLICY_ID_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_-]{1,128}$") |
| 44 | |
| 45 | # _id_hex is retired — use split_id() from muse.core.types directly. |
| 46 | |
| 47 | # --------------------------------------------------------------------------- |
| 48 | # Directory helpers |
| 49 | # --------------------------------------------------------------------------- |
| 50 | |
| 51 | def patterns_dir(root: pathlib.Path) -> pathlib.Path: |
| 52 | """Return ``.muse/harmony/patterns/`` (may not yet exist).""" |
| 53 | return _harmony_dir(root) / _PATTERNS |
| 54 | |
| 55 | def policies_dir(root: pathlib.Path) -> pathlib.Path: |
| 56 | """Return ``.muse/harmony/policies/`` (may not yet exist).""" |
| 57 | return _harmony_dir(root) / _POLICIES |
| 58 | |
| 59 | def audit_dir(root: pathlib.Path) -> pathlib.Path: |
| 60 | """Return ``.muse/harmony/audit/`` (may not yet exist).""" |
| 61 | return _harmony_dir(root) / _AUDIT |
| 62 | |
| 63 | def escalations_dir(root: pathlib.Path) -> pathlib.Path: |
| 64 | """Return ``.muse/harmony/escalations/`` (may not yet exist).""" |
| 65 | return _harmony_dir(root) / _ESCALATIONS |
| 66 | |
| 67 | def escalation_path(root: pathlib.Path, escalation_id: str) -> pathlib.Path: |
| 68 | """Return the JSON file path for an escalation record. |
| 69 | |
| 70 | Path shape: ``.muse/harmony/escalations/<algo>/<hex>.json`` |
| 71 | |
| 72 | Args: |
| 73 | root: Repository root. |
| 74 | escalation_id: A ``<algo>:<hex>`` escalation ID, or bare 64-char hex. |
| 75 | """ |
| 76 | algo, hex_id = split_id(escalation_id) |
| 77 | return escalations_dir(root) / algo / f"{hex_id}.json" |
| 78 | |
| 79 | def pattern_dir(root: pathlib.Path, pattern_id: str) -> pathlib.Path: |
| 80 | """Return the entry directory for *pattern_id*. |
| 81 | |
| 82 | Path shape: ``.muse/harmony/patterns/<algo>/<hex>/`` |
| 83 | |
| 84 | The algorithm is derived from *pattern_id* itself via :func:`split_id`, |
| 85 | so the directory reflects the actual hash algorithm used — no colon ever |
| 86 | appears in a path segment. |
| 87 | |
| 88 | Args: |
| 89 | root: Repository root. |
| 90 | pattern_id: A ``<algo>:<hex>`` pattern ID, or bare 64-char hex. |
| 91 | |
| 92 | Raises: |
| 93 | ValueError: If *pattern_id* is malformed or contains path-unsafe chars. |
| 94 | """ |
| 95 | algo, hex_id = split_id(pattern_id) |
| 96 | return patterns_dir(root) / algo / hex_id |
| 97 | |
| 98 | # Keep old name as an alias so internal callers migrate gradually. |
| 99 | _pattern_entry_dir = pattern_dir |
| 100 | |
| 101 | def _resolutions_dir(root: pathlib.Path, pattern_id: str) -> pathlib.Path: |
| 102 | """Return the resolutions subdirectory for *pattern_id*.""" |
| 103 | return pattern_dir(root, pattern_id) / _RESOLUTIONS |
| 104 | |
| 105 | def resolution_path(root: pathlib.Path, pattern_id: str, resolution_id: str) -> pathlib.Path: |
| 106 | """Return the JSON file path for a resolution record. |
| 107 | |
| 108 | Path shape: ``.muse/harmony/patterns/<p-algo>/<p-hex>/resolutions/<r-algo>/<r-hex>.json`` |
| 109 | |
| 110 | Both the pattern and the resolution carry independent algorithm segments — |
| 111 | they may differ if patterns and resolutions are created at different points |
| 112 | in the system's lifecycle. No colon ever appears in a path segment. |
| 113 | |
| 114 | Args: |
| 115 | root: Repository root. |
| 116 | pattern_id: A ``<algo>:<hex>`` pattern ID, or bare 64-char hex. |
| 117 | resolution_id: A ``<algo>:<hex>`` resolution ID, or bare 64-char hex. |
| 118 | |
| 119 | Raises: |
| 120 | ValueError: If either ID is malformed or contains path-unsafe chars. |
| 121 | """ |
| 122 | r_algo, r_hex = split_id(resolution_id) |
| 123 | return _resolutions_dir(root, pattern_id) / r_algo / f"{r_hex}.json" |
| 124 | |
| 125 | # Keep old name as an alias. |
| 126 | _resolution_path = resolution_path |
| 127 | |
| 128 | # --------------------------------------------------------------------------- |
| 129 | # Validation |
| 130 | # --------------------------------------------------------------------------- |
| 131 | |
| 132 | def _validate_id(value: str, label: str = "id") -> None: |
| 133 | """Raise :exc:`ValueError` if *value* is not a valid content-addressed ID. |
| 134 | |
| 135 | Prevents path-traversal attacks: a crafted value like ``"../traversal"`` cannot |
| 136 | match ``sha256:[0-9a-f]{64}``, so it is rejected before any filesystem path |
| 137 | is constructed from it. |
| 138 | |
| 139 | Args: |
| 140 | value: The string to validate — must be ``sha256:<64-hex>``. |
| 141 | label: Field name used in the error message. |
| 142 | |
| 143 | Raises: |
| 144 | ValueError: When *value* does not match ``sha256:[0-9a-f]{64}``. |
| 145 | """ |
| 146 | if not _SHA256_ID_RE.match(value): |
| 147 | raise ValueError( |
| 148 | f"Invalid {label} {value!r} — expected sha256:<64-hex> (length 71)." |
| 149 | ) |
| 150 | |
| 151 | def _validate_fingerprint(value: str, label: str = "semantic_fingerprint") -> None: |
| 152 | """Raise :exc:`ValueError` if *value* is not a valid semantic fingerprint. |
| 153 | |
| 154 | Unlike :func:`_validate_id`, this does not require hex64 format — domain |
| 155 | plugins may use richer representations such as normalized token-bag |
| 156 | strings produced by ``code_fingerprint()``. |
| 157 | |
| 158 | Constraints: |
| 159 | - Must be non-empty. |
| 160 | - Must not contain null bytes (``\\x00``). |
| 161 | - Must not exceed :data:`_MAX_FINGERPRINT_BYTES` bytes (UTF-8 encoded). |
| 162 | |
| 163 | Args: |
| 164 | value: The fingerprint string to validate. |
| 165 | label: Field name used in the error message. |
| 166 | |
| 167 | Raises: |
| 168 | ValueError: When *value* is empty, contains a null byte, or is too large. |
| 169 | """ |
| 170 | if not value: |
| 171 | raise ValueError(f"Invalid {label}: must not be empty.") |
| 172 | if "\x00" in value: |
| 173 | raise ValueError(f"Invalid {label}: must not contain null bytes.") |
| 174 | if len(value.encode()) > _MAX_FINGERPRINT_BYTES: |
| 175 | raise ValueError( |
| 176 | f"Invalid {label}: exceeds {_MAX_FINGERPRINT_BYTES}-byte limit " |
| 177 | f"({len(value.encode())} bytes)." |
| 178 | ) |
| 179 | |
| 180 | def _validate_policy_id(policy_id: str) -> None: |
| 181 | """Raise :exc:`ValueError` if *policy_id* is not URL-safe. |
| 182 | |
| 183 | Policy IDs are used directly as filenames. This rejects slashes, spaces, |
| 184 | dots, and control characters that could cause path traversal or injection. |
| 185 | |
| 186 | Args: |
| 187 | policy_id: The policy identifier to validate. |
| 188 | |
| 189 | Raises: |
| 190 | ValueError: When *policy_id* does not match ``[A-Za-z0-9_-]{1,128}``. |
| 191 | """ |
| 192 | if not _POLICY_ID_RE.match(policy_id): |
| 193 | raise ValueError( |
| 194 | f"Invalid policy_id {policy_id!r} — " |
| 195 | "only alphanumeric characters, hyphens, and underscores are allowed " |
| 196 | "(1–128 characters)." |
| 197 | ) |
File History
1 commit
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142
docs: add muse-tag-guide mist (complete idiomatic guide wit…
Sonnet 4.6
19 days ago