File size: 14,279 Bytes
8d20d7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | """Allowlisted scalar edits with exact snapshot binding and post-edit verification."""
import json
from collections.abc import Sequence
from difflib import unified_diff
from typing import Never, cast
from ruamel.yaml import YAML
from ruamel.yaml.nodes import ScalarNode
from .models import (
MAX_BATCH_CANDIDATE_BYTES,
MAX_BATCH_OPERATIONS,
MAX_BATCH_SCALAR_BYTES,
BatchEntry,
BatchPreview,
BatchProposal,
BatchRequest,
Candidate,
EditPreview,
EditProposal,
ErrorCode,
Inspection,
Path,
Scalar,
Tree,
YamlEditError,
is_scalar,
validate_batch_proposal,
)
from .parsing import Parsed, parse_source
from .validation import validate_value
def inspect(source: str) -> Inspection:
"""Discover editable fields and bind their identities to this exact source."""
parsed = parse_source(source)
return Inspection(parsed.source_version, parsed.candidates)
def _candidate(parsed: Parsed, candidate_id: str) -> Candidate:
for candidate in parsed.candidates:
if candidate.id == candidate_id:
return candidate
raise YamlEditError(
ErrorCode.UNKNOWN_TARGET, f"Not an editable existing field: {candidate_id!r}."
)
def _same(left: Tree, right: Tree) -> bool:
"""Compare semantic trees without conflating bool/int or int/float."""
if type(left) is not type(right):
return False
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(_same(left[key], right[key]) for key in left)
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
_same(a, b) for a, b in zip(left, right, strict=True)
)
return left == right
def _with_value(tree: Tree, path: Path, value: Scalar) -> Tree:
"""Copy only the selected ancestor chain, retaining the original semantic tree."""
if not path:
return value
head, *tail = path
if isinstance(tree, dict) and isinstance(head, str) and head in tree:
return {**tree, head: _with_value(tree[head], tuple(tail), value)}
if isinstance(tree, list) and type(head) is int and 0 <= head < len(tree):
result = tree.copy()
result[head] = _with_value(tree[head], tuple(tail), value)
return result
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Discovered path does not match the semantic tree."
)
def _plain_string(value: str) -> bool:
# Avoid reinterpreting strings as booleans/numbers in either YAML 1.2 or
# common YAML 1.1 consumers. Parsing never runs arbitrary constructors here.
for version in ((1, 2), (1, 1)):
parser = YAML(typ="safe", pure=True)
parser.version = version
node = parser.compose(value)
if not isinstance(node, ScalarNode) or node.tag != "tag:yaml.org,2002:str":
return False
if node.value != value:
return False
return True
def _replacement(value: Scalar, original: ScalarNode) -> str:
if isinstance(value, str):
if original.style == "'":
return "'" + value.replace("'", "''") + "'"
if original.style != '"' and _plain_string(value):
return value
return json.dumps(value, ensure_ascii=False, allow_nan=False)
def _unified_diff(before: str, after: str) -> str:
if before == after:
return ""
# Review output uses LF regardless of the source's newline convention.
# The returned source itself remains byte-for-byte preserved outside the edit.
lines = unified_diff(
before.replace("\r\n", "\n").replace("\r", "\n").splitlines(keepends=True),
after.replace("\r\n", "\n").replace("\r", "\n").splitlines(keepends=True),
fromfile="before.yaml",
tofile="after.yaml",
)
# difflib doesn't supply the conventional missing-newline marker itself.
return "".join(
line if line.endswith("\n") else line + "\n\\ No newline at end of file\n" for line in lines
)
def _propose_parsed(parsed: Parsed, source: str, candidate_id: str, value: object) -> EditProposal:
"""Create a proposal from a private, request-local parsed source snapshot."""
candidate = _candidate(parsed, candidate_id)
proposal = EditProposal(
source_version=parsed.source_version,
candidate_id=candidate.id,
path=candidate.path,
expected_old_value=candidate.current_value,
value=validate_value(candidate.kind, value),
)
# Includes post-edit structure, request/limit, and invariant checks.
_preview(parsed, source, proposal)
return proposal
def propose(source: str, candidate_id: str, value: object) -> EditProposal:
"""Create a validated proposal. No files are read or written."""
return _propose_parsed(parse_source(source), source, candidate_id, value)
def _preview(parsed: Parsed, source: str, proposal: EditProposal) -> EditPreview:
if parsed.source_version != proposal.source_version:
raise YamlEditError(
ErrorCode.STALE_PROPOSAL, "Source changed; generate and review a new proposal."
)
candidate = _candidate(parsed, proposal.candidate_id)
if candidate.path != proposal.path or not _same(
candidate.current_value, proposal.expected_old_value
):
raise YamlEditError(
ErrorCode.INVALID_PROPOSAL, "Proposal does not match its allowlisted target."
)
value = validate_value(candidate.kind, proposal.value)
if _same(value, candidate.current_value):
return EditPreview(source=source, diff="", changed=False, candidate=candidate)
original = parsed.nodes[candidate.path]
if not isinstance(original, ScalarNode):
raise YamlEditError(ErrorCode.INVARIANT_VIOLATION, "Discovered target is not a scalar.")
start, end = candidate.location.start, candidate.location.end
result = source[:start] + _replacement(value, original) + source[end:]
reparsed = parse_source(result)
expected = _with_value(parsed.data, candidate.path, value)
if not _same(expected, reparsed.data):
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Edit changed more than the intended semantic value."
)
return EditPreview(
source=result, diff=_unified_diff(source, result), changed=True, candidate=candidate
)
def preview(source: str, proposal: object) -> EditPreview:
"""Revalidate an untrusted proposal and return the exact source and review diff."""
checked = EditProposal.from_dict(
proposal.to_dict() if isinstance(proposal, EditProposal) else proposal
)
parsed = parse_source(source)
return _preview(parsed, source, checked)
def apply(source: str, proposal: object) -> str:
"""Apply to an immutable string snapshot; callers handle persistence/confirmation."""
return preview(source, proposal).source
def _batch_invalid() -> Never:
raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Malformed or unsupported batch request.")
def _bounded_request_text(value: object, maximum: int, *, pointer: bool = False) -> str:
if type(value) is not str or len(value) > maximum or (pointer and not value.startswith("/")):
_batch_invalid()
text = value
try:
raw = text.encode("utf-8")
except UnicodeEncodeError as error:
raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Malformed batch request text.") from error
if len(raw) > maximum:
_batch_invalid()
return text
def _batch_requests(value: object) -> tuple[BatchRequest, ...]:
if type(value) not in (list, tuple):
_batch_invalid()
requests = cast("list[object] | tuple[object, ...]", value)
if not 1 <= len(requests) <= MAX_BATCH_OPERATIONS:
_batch_invalid()
checked: list[BatchRequest] = []
for item in requests:
if type(item) is not BatchRequest:
_batch_invalid()
candidate_value = cast("object", getattr(item, "candidate_id", None))
requested_value = cast("object", getattr(item, "value", None))
candidate_id = _bounded_request_text(
candidate_value, MAX_BATCH_CANDIDATE_BYTES, pointer=True
)
if not is_scalar(requested_value):
_batch_invalid()
if type(requested_value) is str:
_bounded_request_text(requested_value, MAX_BATCH_SCALAR_BYTES)
checked.append(BatchRequest(candidate_id, requested_value))
return tuple(checked)
def _resolve_requests(
parsed: Parsed, requests: tuple[BatchRequest, ...]
) -> tuple[tuple[Candidate, Scalar], ...]:
seen: set[str] = set()
resolved: list[tuple[Candidate, Scalar]] = []
for request in requests:
if request.candidate_id in seen:
_batch_invalid()
seen.add(request.candidate_id)
candidate = _candidate(parsed, request.candidate_id)
resolved.append((candidate, validate_value(candidate.kind, request.value)))
return tuple(sorted(resolved, key=lambda item: (item[0].location.start, item[0].id)))
def _proposal_entries(
resolved: tuple[tuple[Candidate, Scalar], ...],
) -> tuple[BatchEntry, ...]:
return tuple(
BatchEntry(candidate.id, candidate.path, candidate.current_value, value)
for candidate, value in resolved
)
def propose_batch(source: str, requests: Sequence[BatchRequest]) -> BatchProposal:
"""Create one validated proposal for explicit existing scalar targets."""
checked_requests = _batch_requests(requests)
parsed = parse_source(source)
resolved = _resolve_requests(parsed, checked_requests)
proposal = BatchProposal(parsed.source_version, _proposal_entries(resolved))
_preview_batch(parsed, source, proposal)
proposal.to_dict()
return proposal
def _entry_matches(candidate: Candidate, entry: BatchEntry) -> bool:
return candidate.path == entry.path and _same(candidate.current_value, entry.expected_old_value)
def _resolve_entries(
parsed: Parsed, proposal: BatchProposal, source_length: int
) -> tuple[tuple[Candidate, Scalar], ...]:
if parsed.source_version != proposal.source_version:
raise YamlEditError(
ErrorCode.STALE_PROPOSAL, "Source changed; generate and review a new batch proposal."
)
ids: set[str] = set()
paths: set[Path] = set()
spans: set[tuple[int, int]] = set()
resolved: list[tuple[Candidate, Scalar]] = []
for entry in proposal.entries:
candidate = _candidate(parsed, entry.candidate_id)
if not _entry_matches(candidate, entry):
raise YamlEditError(
ErrorCode.INVALID_PROPOSAL, "Batch entry does not match its allowlisted target."
)
span = (candidate.location.start, candidate.location.end)
if candidate.id in ids or candidate.path in paths or span in spans:
raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Batch targets must be distinct.")
ids.add(candidate.id)
paths.add(candidate.path)
spans.add(span)
resolved.append((candidate, validate_value(candidate.kind, entry.value)))
canonical = sorted(resolved, key=lambda item: (item[0].location.start, item[0].id))
if resolved != canonical:
raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Batch entries are not in source order.")
_check_batch_spans(resolved, source_length)
return tuple(resolved)
def _check_batch_spans(resolved: list[tuple[Candidate, Scalar]], source_length: int) -> None:
previous_end = -1
for candidate, _value in resolved:
location = candidate.location
if not 0 <= location.start <= location.end <= source_length:
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Discovered batch target span is invalid."
)
if location.start < previous_end:
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Discovered batch target spans overlap."
)
previous_end = location.end
def _batch_expected(parsed: Parsed, resolved: tuple[tuple[Candidate, Scalar], ...]) -> Tree:
expected: Tree = parsed.data
for candidate, value in resolved:
expected = _with_value(expected, candidate.path, value)
return expected
def _batch_source(
parsed: Parsed, source: str, resolved: tuple[tuple[Candidate, Scalar], ...]
) -> str:
result = source
for candidate, value in reversed(resolved):
if _same(candidate.current_value, value):
continue
original = parsed.nodes.get(candidate.path)
if not isinstance(original, ScalarNode):
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Discovered batch target is not a scalar."
)
start, end = candidate.location.start, candidate.location.end
if not 0 <= start <= end <= len(source):
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION, "Discovered batch target span is invalid."
)
result = result[:start] + _replacement(value, original) + result[end:]
return result
def _preview_batch(parsed: Parsed, source: str, proposal: BatchProposal) -> BatchPreview:
resolved = _resolve_entries(parsed, proposal, len(source))
expected = _batch_expected(parsed, resolved)
result = _batch_source(parsed, source, resolved)
reparsed = parse_source(result)
if not _same(expected, reparsed.data):
raise YamlEditError(
ErrorCode.INVARIANT_VIOLATION,
"Batch edit changed more than the intended semantic values.",
)
return BatchPreview(
source=result,
diff=_unified_diff(source, result),
changed=result != source,
candidates=tuple(candidate for candidate, _value in resolved),
)
def preview_batch(source: str, proposal: object) -> BatchPreview:
"""Revalidate an untrusted batch artifact and return its complete review result."""
checked = validate_batch_proposal(proposal)
parsed = parse_source(source)
return _preview_batch(parsed, source, checked)
def apply_batch(source: str, proposal: object) -> str:
"""Apply an atomic batch to an immutable string snapshot."""
return preview_batch(source, proposal).source
|