feat: add multi-engine regex flavour support
This commit is contained in:
216
engines/rust/Cargo.lock
generated
Normal file
216
engines/rust/Cargo.lock
generated
Normal file
@@ -0,0 +1,216 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "regex-tools-rust-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
23
engines/rust/Cargo.toml
Normal file
23
engines/rust/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "regex-tools-rust-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97.1"
|
||||
license = "GPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
regex = "=1.13.1"
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
serde_json = "=1.0.150"
|
||||
wasm-bindgen = "=0.2.126"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
25
engines/rust/README.md
Normal file
25
engines/rust/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Rust regex WebAssembly engine
|
||||
|
||||
This directory builds the actual Rust `regex` crate 1.13.1 for
|
||||
`wasm32-unknown-unknown` with Rust 1.97.1 and wasm-bindgen 0.2.126.
|
||||
|
||||
The fixed bridge accepts JSON values only. The adapter measures the exact
|
||||
encoded request size, and the native cap includes JSON's worst-case sixfold
|
||||
escaping for every accepted string field. It exposes native UTF-8 byte offsets,
|
||||
capture names, iteration, and `Captures::expand` replacement syntax. It does
|
||||
not evaluate user values as Rust or JavaScript source.
|
||||
|
||||
Replacement uses a bounded streaming implementation of
|
||||
`Captures::expand` grammar. Load-time differential fixtures compare it with
|
||||
the native API, and capture references stop expanding as soon as the UTF-8
|
||||
output cap is reached.
|
||||
The bounded bytes cross the JSON bridge as base64, so control-heavy output has
|
||||
a fixed 4:3 transport expansion instead of JSON's content-dependent escaping.
|
||||
|
||||
Flags `i`, `m`, `s`, `U`, `x`, and `R` map directly to `RegexBuilder`.
|
||||
Unicode is the crate's native default; the accepted `u` flag explicitly
|
||||
affirms that default. `g` selects the crate's native iterator.
|
||||
|
||||
The crate deliberately excludes look-around and backreferences. Compile-size,
|
||||
DFA-cache, pattern, subject, match, capture-row, and output limits are enforced
|
||||
inside the module. Replacement output is clipped only at a UTF-8 boundary.
|
||||
4
engines/rust/rust-toolchain.toml
Normal file
4
engines/rust/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
profile = "minimal"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
574
engines/rust/src/lib.rs
Normal file
574
engines/rust/src/lib.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! Fixed, bounded WebAssembly bridge for Rust's `regex` crate 1.13.1.
|
||||
//!
|
||||
//! User-controlled pattern, subject and replacement values enter through
|
||||
//! deserialized JSON. They are never interpreted as Rust or JavaScript source.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use regex::{Captures, Regex, RegexBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
const BRIDGE_ABI_VERSION: u32 = 1;
|
||||
const REGEX_CRATE_VERSION: &str = "1.13.1";
|
||||
const RUSTC_VERSION: &str = env!("REGEX_TOOLS_RUSTC_VERSION");
|
||||
const EXPECTED_RUSTC_VERSION: &str = "rustc 1.97.1 (8bab26f4f 2026-07-14)";
|
||||
const MAXIMUM_PATTERN_BYTES: usize = 256 * 1024;
|
||||
const MAXIMUM_SUBJECT_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAXIMUM_REPLACEMENT_BYTES: usize = 256 * 1024;
|
||||
const MAXIMUM_MATCHES: usize = 10_000;
|
||||
const MAXIMUM_CAPTURE_ROWS: usize = 100_000;
|
||||
const MAXIMUM_CAPTURE_GROUPS: usize = 1_000;
|
||||
const MAXIMUM_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAXIMUM_REQUEST_JSON_BYTES: usize =
|
||||
6 * (MAXIMUM_SUBJECT_BYTES + MAXIMUM_PATTERN_BYTES + MAXIMUM_REPLACEMENT_BYTES)
|
||||
+ 64 * 1024;
|
||||
const MAXIMUM_ERROR_MESSAGE_BYTES: usize = 1_024;
|
||||
const COMPILED_SIZE_LIMIT_BYTES: usize = 10 * 1024 * 1024;
|
||||
const DFA_SIZE_LIMIT_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct BridgeRequest {
|
||||
abi: u32,
|
||||
operation: String,
|
||||
pattern: String,
|
||||
flags: String,
|
||||
options: BTreeMap<String, Value>,
|
||||
subject: String,
|
||||
scan_all: bool,
|
||||
maximum_matches: usize,
|
||||
maximum_capture_rows: usize,
|
||||
replacement: Option<String>,
|
||||
maximum_output_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BridgeFailure {
|
||||
abi: u32,
|
||||
ok: bool,
|
||||
phase: &'static str,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NativeMatch {
|
||||
span: [usize; 2],
|
||||
captures: Vec<Option<[usize; 2]>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BridgeSuccess {
|
||||
abi: u32,
|
||||
ok: bool,
|
||||
group_count: usize,
|
||||
group_names: Vec<(usize, String)>,
|
||||
matches: Vec<NativeMatch>,
|
||||
results_truncated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output_base64: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output_bytes: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output_truncated: Option<bool>,
|
||||
}
|
||||
|
||||
fn bounded_message(value: impl std::fmt::Display) -> String {
|
||||
let value = value.to_string();
|
||||
if value.len() <= MAXIMUM_ERROR_MESSAGE_BYTES {
|
||||
return value;
|
||||
}
|
||||
let mut end = MAXIMUM_ERROR_MESSAGE_BYTES - "…".len();
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &value[..end])
|
||||
}
|
||||
|
||||
fn serialize<T: Serialize>(value: &T) -> String {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(encoded) => encoded,
|
||||
Err(_) => {
|
||||
"{\"abi\":1,\"ok\":false,\"phase\":\"bridge\",\"code\":\"serialization-error\",\"message\":\"The Rust bridge could not serialize its bounded result.\"}".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(phase: &'static str, code: &'static str, message: impl std::fmt::Display) -> String {
|
||||
serialize(&BridgeFailure {
|
||||
abi: BRIDGE_ABI_VERSION,
|
||||
ok: false,
|
||||
phase,
|
||||
code,
|
||||
message: bounded_message(message),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_request(request: &BridgeRequest) -> Result<(), String> {
|
||||
if request.abi != BRIDGE_ABI_VERSION {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"abi-mismatch",
|
||||
"The bridge ABI version is unsupported.",
|
||||
));
|
||||
}
|
||||
if request.operation != "execute" && request.operation != "replace" {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"invalid-operation",
|
||||
"The bridge operation is unsupported.",
|
||||
));
|
||||
}
|
||||
if !request.options.is_empty() {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"unsupported-option",
|
||||
"Rust regex does not accept engine options.",
|
||||
));
|
||||
}
|
||||
if request.pattern.len() > MAXIMUM_PATTERN_BYTES {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"pattern-limit",
|
||||
"The pattern exceeds the bridge limit.",
|
||||
));
|
||||
}
|
||||
if request.subject.len() > MAXIMUM_SUBJECT_BYTES {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"subject-limit",
|
||||
"The subject exceeds the bridge limit.",
|
||||
));
|
||||
}
|
||||
if request.maximum_matches == 0
|
||||
|| request.maximum_matches > MAXIMUM_MATCHES
|
||||
|| request.maximum_capture_rows == 0
|
||||
|| request.maximum_capture_rows > MAXIMUM_CAPTURE_ROWS
|
||||
{
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"result-limit",
|
||||
"The requested result bounds are outside the bridge contract.",
|
||||
));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for flag in request.flags.chars() {
|
||||
if !"gimsUuxR".contains(flag) || !seen.insert(flag) {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"unsupported-flag",
|
||||
"The request contains an unsupported or duplicate Rust flag.",
|
||||
));
|
||||
}
|
||||
}
|
||||
if request.operation == "execute" {
|
||||
if request.replacement.is_some() || request.maximum_output_bytes.is_some() {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"invalid-request",
|
||||
"Execution requests cannot contain replacement fields.",
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(replacement) = &request.replacement else {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"invalid-request",
|
||||
"Replacement requests require a template.",
|
||||
));
|
||||
};
|
||||
let Some(output_limit) = request.maximum_output_bytes else {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"invalid-request",
|
||||
"Replacement requests require an output bound.",
|
||||
));
|
||||
};
|
||||
if replacement.len() > MAXIMUM_REPLACEMENT_BYTES {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"replacement-limit",
|
||||
"The replacement exceeds the bridge limit.",
|
||||
));
|
||||
}
|
||||
if output_limit == 0 || output_limit > MAXIMUM_OUTPUT_BYTES {
|
||||
return Err(failure(
|
||||
"bridge",
|
||||
"output-limit",
|
||||
"The output bound is outside the bridge contract.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compile(request: &BridgeRequest) -> Result<Regex, String> {
|
||||
let has = |flag| request.flags.contains(flag);
|
||||
let mut builder = RegexBuilder::new(&request.pattern);
|
||||
builder
|
||||
.case_insensitive(has('i'))
|
||||
.multi_line(has('m'))
|
||||
.dot_matches_new_line(has('s'))
|
||||
.swap_greed(has('U'))
|
||||
// Unicode is Rust regex's native default. The accepted `u` flag is an
|
||||
// explicit affirmation, not a request to disable Unicode when absent.
|
||||
.unicode(true)
|
||||
.ignore_whitespace(has('x'))
|
||||
.crlf(has('R'))
|
||||
.size_limit(COMPILED_SIZE_LIMIT_BYTES)
|
||||
.dfa_size_limit(DFA_SIZE_LIMIT_BYTES);
|
||||
builder
|
||||
.build()
|
||||
.map_err(|error| failure("compile", "compile-error", error))
|
||||
}
|
||||
|
||||
fn materialize(captures: &Captures<'_>, group_count: usize) -> NativeMatch {
|
||||
let whole = captures
|
||||
.get(0)
|
||||
.expect("regex captures always contain the complete match");
|
||||
let groups = (1..=group_count)
|
||||
.map(|group| {
|
||||
captures
|
||||
.get(group)
|
||||
.map(|capture| [capture.start(), capture.end()])
|
||||
})
|
||||
.collect();
|
||||
NativeMatch {
|
||||
span: [whole.start(), whole.end()],
|
||||
captures: groups,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_matches(
|
||||
compiled: &Regex,
|
||||
request: &BridgeRequest,
|
||||
group_count: usize,
|
||||
) -> (Vec<NativeMatch>, bool) {
|
||||
let rows_per_match = group_count.max(1);
|
||||
let allowed = request
|
||||
.maximum_matches
|
||||
.min(request.maximum_capture_rows / rows_per_match);
|
||||
let global = request.flags.contains('g');
|
||||
let mut records = Vec::with_capacity(allowed.min(256));
|
||||
let mut truncated = false;
|
||||
if global {
|
||||
for captures in compiled.captures_iter(&request.subject) {
|
||||
if records.len() == allowed {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
records.push(materialize(&captures, group_count));
|
||||
}
|
||||
} else if let Some(captures) = compiled.captures(&request.subject) {
|
||||
if allowed == 0 {
|
||||
truncated = true;
|
||||
} else {
|
||||
records.push(materialize(&captures, group_count));
|
||||
}
|
||||
}
|
||||
(records, truncated)
|
||||
}
|
||||
|
||||
struct BoundedOutput {
|
||||
value: String,
|
||||
maximum: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl BoundedOutput {
|
||||
fn new(maximum: usize) -> Self {
|
||||
Self {
|
||||
value: String::with_capacity(maximum.min(64 * 1024)),
|
||||
maximum,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, value: &str) {
|
||||
if self.truncated || value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let remaining = self.maximum - self.value.len();
|
||||
if value.len() <= remaining {
|
||||
self.value.push_str(value);
|
||||
return;
|
||||
}
|
||||
let mut end = remaining;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
self.value.push_str(&value[..end]);
|
||||
self.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
enum ReplacementReference<'a> {
|
||||
Number(usize),
|
||||
Named(&'a str),
|
||||
}
|
||||
|
||||
fn capture_reference(replacement: &str) -> Option<(ReplacementReference<'_>, usize)> {
|
||||
let bytes = replacement.as_bytes();
|
||||
if bytes.len() <= 1 || bytes[0] != b'$' {
|
||||
return None;
|
||||
}
|
||||
let (name, end) = if bytes[1] == b'{' {
|
||||
let close = bytes[2..].iter().position(|&byte| byte == b'}')? + 2;
|
||||
(&replacement[2..close], close + 1)
|
||||
} else {
|
||||
let mut end = 1;
|
||||
while bytes
|
||||
.get(end)
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
if end == 1 {
|
||||
return None;
|
||||
}
|
||||
(&replacement[1..end], end)
|
||||
};
|
||||
let reference = match name.parse::<usize>() {
|
||||
Ok(number) => ReplacementReference::Number(number),
|
||||
Err(_) => ReplacementReference::Named(name),
|
||||
};
|
||||
Some((reference, end))
|
||||
}
|
||||
|
||||
fn expand_replacement_bounded(
|
||||
output: &mut BoundedOutput,
|
||||
captures: &Captures<'_>,
|
||||
mut replacement: &str,
|
||||
) {
|
||||
while !replacement.is_empty() && !output.truncated {
|
||||
let Some(dollar) = replacement.as_bytes().iter().position(|&byte| byte == b'$') else {
|
||||
output.append(replacement);
|
||||
return;
|
||||
};
|
||||
output.append(&replacement[..dollar]);
|
||||
if output.truncated {
|
||||
return;
|
||||
}
|
||||
replacement = &replacement[dollar..];
|
||||
if replacement.as_bytes().get(1) == Some(&b'$') {
|
||||
output.append("$");
|
||||
replacement = &replacement[2..];
|
||||
continue;
|
||||
}
|
||||
let Some((reference, end)) = capture_reference(replacement) else {
|
||||
output.append("$");
|
||||
replacement = &replacement[1..];
|
||||
continue;
|
||||
};
|
||||
replacement = &replacement[end..];
|
||||
let capture = match reference {
|
||||
ReplacementReference::Number(number) => captures.get(number),
|
||||
ReplacementReference::Named(name) => captures.name(name),
|
||||
};
|
||||
if let Some(capture) = capture {
|
||||
output.append(capture.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_base64(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut encoded = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let first = chunk[0];
|
||||
let second = chunk.get(1).copied().unwrap_or(0);
|
||||
let third = chunk.get(2).copied().unwrap_or(0);
|
||||
encoded.push(ALPHABET[usize::from(first >> 2)] as char);
|
||||
encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
|
||||
encoded.push(if chunk.len() > 1 {
|
||||
ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
encoded.push(if chunk.len() > 2 {
|
||||
ALPHABET[usize::from(third & 0x3f)] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn replace_bounded(
|
||||
compiled: &Regex,
|
||||
request: &BridgeRequest,
|
||||
accepted_matches: usize,
|
||||
) -> (String, usize, bool) {
|
||||
let replacement = request
|
||||
.replacement
|
||||
.as_deref()
|
||||
.expect("validated replacement request");
|
||||
let output_limit = request
|
||||
.maximum_output_bytes
|
||||
.expect("validated replacement request");
|
||||
let mut output = BoundedOutput::new(output_limit);
|
||||
let mut cursor = 0;
|
||||
let global = request.flags.contains('g');
|
||||
let maximum = if global {
|
||||
accepted_matches
|
||||
} else {
|
||||
accepted_matches.min(1)
|
||||
};
|
||||
for captures in compiled.captures_iter(&request.subject).take(maximum) {
|
||||
let whole = captures
|
||||
.get(0)
|
||||
.expect("regex captures always contain the complete match");
|
||||
output.append(&request.subject[cursor..whole.start()]);
|
||||
expand_replacement_bounded(&mut output, &captures, replacement);
|
||||
cursor = whole.end();
|
||||
}
|
||||
output.append(&request.subject[cursor..]);
|
||||
let bytes = output.value.len();
|
||||
(
|
||||
encode_base64(output.value.as_bytes()),
|
||||
bytes,
|
||||
output.truncated,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute(encoded: &str) -> String {
|
||||
if encoded.len() > MAXIMUM_REQUEST_JSON_BYTES {
|
||||
return failure(
|
||||
"bridge",
|
||||
"request-limit",
|
||||
"The encoded request exceeds the bridge limit.",
|
||||
);
|
||||
}
|
||||
let request: BridgeRequest = match serde_json::from_str(encoded) {
|
||||
Ok(request) => request,
|
||||
Err(error) => return failure("bridge", "invalid-request", error),
|
||||
};
|
||||
if let Err(error) = validate_request(&request) {
|
||||
return error;
|
||||
}
|
||||
// Reading the field also keeps the transport contract explicit: matching
|
||||
// is selected by the normalized native `g` flag, while scanAll records the
|
||||
// application request that caused an internal `g` to be added.
|
||||
let _scan_all = request.scan_all;
|
||||
let compiled = match compile(&request) {
|
||||
Ok(compiled) => compiled,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let group_count = compiled.captures_len() - 1;
|
||||
if group_count > MAXIMUM_CAPTURE_GROUPS {
|
||||
return failure(
|
||||
"compile",
|
||||
"capture-group-limit",
|
||||
format!(
|
||||
"The pattern declares {group_count} capture groups; the bridge limit is {MAXIMUM_CAPTURE_GROUPS}."
|
||||
),
|
||||
);
|
||||
}
|
||||
let group_names = compiled
|
||||
.capture_names()
|
||||
.enumerate()
|
||||
.filter_map(|(number, name)| name.map(|name| (number, name.to_owned())))
|
||||
.filter(|(number, _)| *number > 0)
|
||||
.collect();
|
||||
let (matches, results_truncated) = collect_matches(&compiled, &request, group_count);
|
||||
let mut result = BridgeSuccess {
|
||||
abi: BRIDGE_ABI_VERSION,
|
||||
ok: true,
|
||||
group_count,
|
||||
group_names,
|
||||
matches,
|
||||
results_truncated,
|
||||
output_base64: None,
|
||||
output_bytes: None,
|
||||
output_truncated: None,
|
||||
};
|
||||
if request.operation == "replace" {
|
||||
let (output_base64, output_bytes, output_truncated) =
|
||||
replace_bounded(&compiled, &request, result.matches.len());
|
||||
result.output_base64 = Some(output_base64);
|
||||
result.output_bytes = Some(output_bytes);
|
||||
result.output_truncated = Some(output_truncated);
|
||||
}
|
||||
serialize(&result)
|
||||
}
|
||||
|
||||
fn self_test() -> bool {
|
||||
if RUSTC_VERSION != EXPECTED_RUSTC_VERSION || encode_base64("😀".as_bytes()) != "8J+YgA==" {
|
||||
return false;
|
||||
}
|
||||
let Ok(compiled) = Regex::new(r"(?P<word>😀+)") else {
|
||||
return false;
|
||||
};
|
||||
let Some(captures) = compiled.captures("x😀😀") else {
|
||||
return false;
|
||||
};
|
||||
let Some(whole) = captures.get(0) else {
|
||||
return false;
|
||||
};
|
||||
let Some(word) = captures.name("word") else {
|
||||
return false;
|
||||
};
|
||||
if whole.start() != 1 || whole.end() != 9 || word.start() != 1 || word.end() != 9 {
|
||||
return false;
|
||||
}
|
||||
for template in [
|
||||
"${word}-$word-$$",
|
||||
"$1x|${1}x",
|
||||
"${missing}|$|${word",
|
||||
"$01|${01}|$123456789012345678901234567890",
|
||||
"${}|${word}",
|
||||
] {
|
||||
let mut native = String::new();
|
||||
captures.expand(template, &mut native);
|
||||
let mut bounded = BoundedOutput::new(4 * 1024);
|
||||
expand_replacement_bounded(&mut bounded, &captures, template);
|
||||
if bounded.truncated || bounded.value != native {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mut adversarial = BoundedOutput::new(5);
|
||||
expand_replacement_bounded(&mut adversarial, &captures, &"${word}".repeat(32 * 1024));
|
||||
adversarial.truncated && adversarial.value == "😀"
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn identity() -> String {
|
||||
let mut identity = Map::new();
|
||||
identity.insert(
|
||||
"bridge".to_owned(),
|
||||
Value::String("regex-tools-rust-json".to_owned()),
|
||||
);
|
||||
identity.insert("bridgeVersion".to_owned(), Value::from(BRIDGE_ABI_VERSION));
|
||||
identity.insert(
|
||||
"engine".to_owned(),
|
||||
Value::String("Rust regex crate".to_owned()),
|
||||
);
|
||||
identity.insert(
|
||||
"engineVersion".to_owned(),
|
||||
Value::String(REGEX_CRATE_VERSION.to_owned()),
|
||||
);
|
||||
identity.insert(
|
||||
"offsetUnit".to_owned(),
|
||||
Value::String("utf8-byte".to_owned()),
|
||||
);
|
||||
identity.insert(
|
||||
"rustcVersion".to_owned(),
|
||||
Value::String(RUSTC_VERSION.to_owned()),
|
||||
);
|
||||
identity.insert("selfTest".to_owned(), Value::Bool(self_test()));
|
||||
let mut envelope = Map::new();
|
||||
envelope.insert("abi".to_owned(), Value::from(BRIDGE_ABI_VERSION));
|
||||
envelope.insert("ok".to_owned(), Value::Bool(true));
|
||||
envelope.insert("identity".to_owned(), Value::Object(identity));
|
||||
serialize(&Value::Object(envelope))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run(encoded: &str) -> String {
|
||||
execute(encoded)
|
||||
}
|
||||
Reference in New Issue
Block a user