278 lines
8.3 KiB
Python
278 lines
8.3 KiB
Python
"""Fixed, bounded CPython ``re`` bridge used by the Python engine worker.
|
|
|
|
This file is bundled as source by Vite and evaluated once while the trusted
|
|
Pyodide runtime starts. User-controlled values are passed as function
|
|
arguments; they are never evaluated as Python source.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
|
|
_BRIDGE_ABI_VERSION = 1
|
|
_MAX_CAPTURE_GROUPS = 1_000
|
|
_MAX_NATIVE_EXPANSION_CODE_POINTS = 16 * 1024 * 1024
|
|
|
|
|
|
def _json_result(value):
|
|
return json.dumps(value, ensure_ascii=True, separators=(",", ":"))
|
|
|
|
|
|
def _error(phase, code, error, position=None):
|
|
message = str(error)
|
|
if len(message) > 1_024:
|
|
message = message[:1_023] + "\N{HORIZONTAL ELLIPSIS}"
|
|
result = {
|
|
"abi": _BRIDGE_ABI_VERSION,
|
|
"ok": False,
|
|
"phase": phase,
|
|
"code": code,
|
|
"message": message,
|
|
}
|
|
if isinstance(position, int) and position >= 0:
|
|
result["position"] = position
|
|
return _json_result(result)
|
|
|
|
|
|
def _flag_bits(flags):
|
|
bits = 0
|
|
if "a" in flags:
|
|
bits |= re.ASCII
|
|
if "i" in flags:
|
|
bits |= re.IGNORECASE
|
|
if "m" in flags:
|
|
bits |= re.MULTILINE
|
|
if "s" in flags:
|
|
bits |= re.DOTALL
|
|
if "x" in flags:
|
|
bits |= re.VERBOSE
|
|
return bits
|
|
|
|
|
|
def _match_record(match, group_count):
|
|
captures = []
|
|
for group_number in range(1, group_count + 1):
|
|
start, end = match.span(group_number)
|
|
captures.append(None if start < 0 else [start, end])
|
|
start, end = match.span(0)
|
|
return {"span": [start, end], "captures": captures}
|
|
|
|
|
|
def _utf8_width(character):
|
|
code_point = ord(character)
|
|
if code_point <= 0x7F:
|
|
return 1
|
|
if code_point <= 0x7FF:
|
|
return 2
|
|
if code_point <= 0xFFFF:
|
|
return 3
|
|
return 4
|
|
|
|
|
|
def _append_utf8_bounded(chunks, value, byte_count, maximum_bytes):
|
|
remaining = maximum_bytes - byte_count
|
|
if not value:
|
|
return byte_count, True
|
|
end = 0
|
|
for character in value:
|
|
width = _utf8_width(character)
|
|
if width > remaining:
|
|
break
|
|
remaining -= width
|
|
byte_count += width
|
|
end += 1
|
|
if end:
|
|
chunks.append(value[:end])
|
|
return byte_count, end == len(value)
|
|
|
|
|
|
def _expansion_code_points(parsed_template, match):
|
|
total = 0
|
|
for part in parsed_template:
|
|
if isinstance(part, int):
|
|
start, end = match.span(part)
|
|
if start >= 0:
|
|
total += end - start
|
|
else:
|
|
total += len(part)
|
|
if total > _MAX_NATIVE_EXPANSION_CODE_POINTS:
|
|
return total
|
|
return total
|
|
|
|
|
|
def _identity():
|
|
return _json_result(
|
|
{
|
|
"abi": _BRIDGE_ABI_VERSION,
|
|
"ok": True,
|
|
"identity": {
|
|
"implementation": sys.implementation.name,
|
|
"pythonVersion": ".".join(
|
|
str(part) for part in sys.version_info[:3]
|
|
),
|
|
"reModule": re.__name__,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _regex_tools_run(
|
|
operation,
|
|
pattern,
|
|
flags,
|
|
subject,
|
|
maximum_matches,
|
|
maximum_capture_rows,
|
|
replacement,
|
|
maximum_output_bytes,
|
|
):
|
|
if operation == "identity":
|
|
return _identity()
|
|
if operation not in ("execute", "replace"):
|
|
return _error("bridge", "invalid-operation", "Unsupported bridge operation.")
|
|
|
|
try:
|
|
compiled = re.compile(pattern, _flag_bits(flags))
|
|
except re.PatternError as error:
|
|
return _error(
|
|
"compile",
|
|
"compile-error",
|
|
error,
|
|
getattr(error, "pos", None),
|
|
)
|
|
except Exception as error:
|
|
return _error("compile", "compile-error", error)
|
|
|
|
group_count = compiled.groups
|
|
if group_count > _MAX_CAPTURE_GROUPS:
|
|
return _error(
|
|
"compile",
|
|
"capture-group-limit",
|
|
(
|
|
f"The pattern declares {group_count} capture groups; "
|
|
f"the bridge limit is {_MAX_CAPTURE_GROUPS}."
|
|
),
|
|
)
|
|
|
|
group_names = sorted(
|
|
([group_number, name] for name, group_number in compiled.groupindex.items()),
|
|
key=lambda item: item[0],
|
|
)
|
|
|
|
parsed_template = None
|
|
if operation == "replace":
|
|
try:
|
|
parsed_template = re._parser.parse_template(replacement, compiled)
|
|
except (re.PatternError, IndexError) as error:
|
|
return _error(
|
|
"replacement",
|
|
"replacement-error",
|
|
error,
|
|
getattr(error, "pos", None),
|
|
)
|
|
except Exception as error:
|
|
return _error("replacement", "replacement-error", error)
|
|
|
|
iterate = "g" in flags
|
|
try:
|
|
matches = compiled.finditer(subject) if iterate else (
|
|
candidate for candidate in (compiled.search(subject),) if candidate
|
|
)
|
|
records = []
|
|
capture_rows = 0
|
|
results_truncated = False
|
|
output_chunks = []
|
|
output_bytes = 0
|
|
output_truncated = False
|
|
output_stopped = False
|
|
next_source_position = 0
|
|
|
|
for match in matches:
|
|
rows_for_match = max(1, group_count)
|
|
if (
|
|
len(records) >= maximum_matches
|
|
or capture_rows + rows_for_match > maximum_capture_rows
|
|
):
|
|
results_truncated = True
|
|
break
|
|
|
|
records.append(_match_record(match, group_count))
|
|
capture_rows += rows_for_match
|
|
|
|
if operation == "replace" and not output_stopped:
|
|
start, end = match.span(0)
|
|
output_bytes, complete = _append_utf8_bounded(
|
|
output_chunks,
|
|
subject[next_source_position:start],
|
|
output_bytes,
|
|
maximum_output_bytes,
|
|
)
|
|
if not complete:
|
|
output_truncated = True
|
|
output_stopped = True
|
|
else:
|
|
expansion_size = _expansion_code_points(
|
|
parsed_template, match
|
|
)
|
|
if expansion_size > _MAX_NATIVE_EXPANSION_CODE_POINTS:
|
|
return _error(
|
|
"replacement",
|
|
"replacement-expansion-limit",
|
|
(
|
|
"One native match expansion exceeds the "
|
|
f"{_MAX_NATIVE_EXPANSION_CODE_POINTS} "
|
|
"code-point safety limit."
|
|
),
|
|
)
|
|
expanded = match.expand(replacement)
|
|
output_bytes, complete = _append_utf8_bounded(
|
|
output_chunks,
|
|
expanded,
|
|
output_bytes,
|
|
maximum_output_bytes,
|
|
)
|
|
if not complete:
|
|
output_truncated = True
|
|
output_stopped = True
|
|
next_source_position = end
|
|
|
|
if operation == "replace" and not output_stopped:
|
|
output_bytes, complete = _append_utf8_bounded(
|
|
output_chunks,
|
|
subject[next_source_position:],
|
|
output_bytes,
|
|
maximum_output_bytes,
|
|
)
|
|
if not complete:
|
|
output_truncated = True
|
|
|
|
result = {
|
|
"abi": _BRIDGE_ABI_VERSION,
|
|
"ok": True,
|
|
"groupCount": group_count,
|
|
"groupNames": group_names,
|
|
"matches": records,
|
|
"resultsTruncated": results_truncated,
|
|
}
|
|
if operation == "replace":
|
|
result.update(
|
|
{
|
|
"output": "".join(output_chunks),
|
|
"outputBytes": output_bytes,
|
|
"outputTruncated": output_truncated,
|
|
}
|
|
)
|
|
return _json_result(result)
|
|
except re.PatternError as error:
|
|
phase = "replacement" if operation == "replace" else "match"
|
|
code = "replacement-error" if operation == "replace" else "match-error"
|
|
return _error(phase, code, error, getattr(error, "pos", None))
|
|
except Exception as error:
|
|
phase = "replacement" if operation == "replace" else "match"
|
|
code = "replacement-error" if operation == "replace" else "match-error"
|
|
return _error(phase, code, error)
|
|
|
|
|
|
_regex_tools_run
|