feat: add multi-engine regex flavour support
This commit is contained in:
521
engines/go/main.go
Normal file
521
engines/go/main.go
Normal file
@@ -0,0 +1,521 @@
|
||||
// Regex Tools Go engine bridge.
|
||||
//
|
||||
// The WebAssembly module exposes two fixed callbacks. User-controlled values
|
||||
// enter only through JSON data and are handed to the standard-library regexp
|
||||
// package; no Go or JavaScript source is evaluated.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
bridgeABIVersion = 1
|
||||
expectedGoVersion = "go1.26.5"
|
||||
maximumPatternBytes = 256 * 1024
|
||||
maximumSubjectBytes = 16 * 1024 * 1024
|
||||
maximumReplacementBytes = 256 * 1024
|
||||
maximumMatches = 10_000
|
||||
maximumCaptureRows = 100_000
|
||||
maximumCaptureGroups = 1_000
|
||||
maximumOutputBytes = 64 * 1024 * 1024
|
||||
maximumRequestJSONBytes = 6*(maximumSubjectBytes+maximumPatternBytes+maximumReplacementBytes) + 64*1024
|
||||
maximumErrorMessageBytes = 1_024
|
||||
bridgeInstallerGlobalName = "__regexToolsInstallGoBridge"
|
||||
)
|
||||
|
||||
type bridgeRequest struct {
|
||||
ABI int `json:"abi"`
|
||||
Operation string `json:"operation"`
|
||||
Pattern string `json:"pattern"`
|
||||
Flags string `json:"flags"`
|
||||
Options map[string]json.RawMessage `json:"options"`
|
||||
Subject string `json:"subject"`
|
||||
ScanAll bool `json:"scanAll"`
|
||||
MaximumMatches int `json:"maximumMatches"`
|
||||
MaximumCaptureRows int `json:"maximumCaptureRows"`
|
||||
Replacement *string `json:"replacement,omitempty"`
|
||||
MaximumOutputBytes *int `json:"maximumOutputBytes,omitempty"`
|
||||
}
|
||||
|
||||
type bridgeFailure struct {
|
||||
ABI int `json:"abi"`
|
||||
OK bool `json:"ok"`
|
||||
Phase string `json:"phase"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type nativeMatch struct {
|
||||
Span [2]int `json:"span"`
|
||||
Captures []*[2]int `json:"captures"`
|
||||
}
|
||||
|
||||
type bridgeSuccess struct {
|
||||
ABI int `json:"abi"`
|
||||
OK bool `json:"ok"`
|
||||
GroupCount int `json:"groupCount"`
|
||||
GroupNames [][2]any `json:"groupNames"`
|
||||
Matches []nativeMatch `json:"matches"`
|
||||
ResultsTruncated bool `json:"resultsTruncated"`
|
||||
OutputBase64 *string `json:"outputBase64,omitempty"`
|
||||
OutputBytes *int `json:"outputBytes,omitempty"`
|
||||
OutputTruncated *bool `json:"outputTruncated,omitempty"`
|
||||
}
|
||||
|
||||
type identityEnvelope struct {
|
||||
ABI int `json:"abi"`
|
||||
OK bool `json:"ok"`
|
||||
Identity map[string]any `json:"identity"`
|
||||
}
|
||||
|
||||
func boundedMessage(value string) string {
|
||||
if len(value) <= maximumErrorMessageBytes {
|
||||
return value
|
||||
}
|
||||
end := maximumErrorMessageBytes - len("…")
|
||||
for end > 0 && !utf8.ValidString(value[:end]) {
|
||||
end--
|
||||
}
|
||||
return value[:end] + "…"
|
||||
}
|
||||
|
||||
func marshal(value any) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err == nil {
|
||||
return string(encoded)
|
||||
}
|
||||
fallback, _ := json.Marshal(bridgeFailure{
|
||||
ABI: bridgeABIVersion,
|
||||
OK: false,
|
||||
Phase: "bridge",
|
||||
Code: "serialization-error",
|
||||
Message: "The Go bridge could not serialize its bounded result.",
|
||||
})
|
||||
return string(fallback)
|
||||
}
|
||||
|
||||
func failure(phase, code string, err any) string {
|
||||
return marshal(bridgeFailure{
|
||||
ABI: bridgeABIVersion,
|
||||
OK: false,
|
||||
Phase: phase,
|
||||
Code: code,
|
||||
Message: boundedMessage(fmt.Sprint(err)),
|
||||
})
|
||||
}
|
||||
|
||||
func decodeRequest(encoded string) (bridgeRequest, string) {
|
||||
var request bridgeRequest
|
||||
if len(encoded) > maximumRequestJSONBytes {
|
||||
return request, failure("bridge", "request-limit", "The encoded request exceeds the bridge limit.")
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(encoded))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return request, failure("bridge", "invalid-request", err)
|
||||
}
|
||||
if decoder.More() {
|
||||
return request, failure("bridge", "invalid-request", "The encoded request contains trailing JSON values.")
|
||||
}
|
||||
return request, ""
|
||||
}
|
||||
|
||||
func validateRequest(request bridgeRequest) string {
|
||||
if request.ABI != bridgeABIVersion {
|
||||
return failure("bridge", "abi-mismatch", "The bridge ABI version is unsupported.")
|
||||
}
|
||||
if request.Operation != "execute" && request.Operation != "replace" {
|
||||
return failure("bridge", "invalid-operation", "The bridge operation is unsupported.")
|
||||
}
|
||||
if len(request.Options) != 0 {
|
||||
return failure("bridge", "unsupported-option", "Go regexp does not accept engine options.")
|
||||
}
|
||||
if !utf8.ValidString(request.Pattern) || len(request.Pattern) > maximumPatternBytes {
|
||||
return failure("bridge", "pattern-limit", "The pattern is invalid UTF-8 or exceeds the bridge limit.")
|
||||
}
|
||||
if !utf8.ValidString(request.Subject) || len(request.Subject) > maximumSubjectBytes {
|
||||
return failure("bridge", "subject-limit", "The subject is invalid UTF-8 or exceeds the bridge limit.")
|
||||
}
|
||||
if request.MaximumMatches < 1 || request.MaximumMatches > maximumMatches ||
|
||||
request.MaximumCaptureRows < 1 || request.MaximumCaptureRows > maximumCaptureRows {
|
||||
return failure("bridge", "result-limit", "The requested result bounds are outside the bridge contract.")
|
||||
}
|
||||
seenFlags := make(map[rune]bool)
|
||||
for _, flag := range request.Flags {
|
||||
if !strings.ContainsRune("gimsU", flag) || seenFlags[flag] {
|
||||
return failure("bridge", "unsupported-flag", "The request contains an unsupported or duplicate Go flag.")
|
||||
}
|
||||
seenFlags[flag] = true
|
||||
}
|
||||
if request.Operation == "execute" {
|
||||
if request.Replacement != nil || request.MaximumOutputBytes != nil {
|
||||
return failure("bridge", "invalid-request", "Execution requests cannot contain replacement fields.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if request.Replacement == nil || request.MaximumOutputBytes == nil {
|
||||
return failure("bridge", "invalid-request", "Replacement requests require a template and output bound.")
|
||||
}
|
||||
if !utf8.ValidString(*request.Replacement) || len(*request.Replacement) > maximumReplacementBytes {
|
||||
return failure("bridge", "replacement-limit", "The replacement is invalid UTF-8 or exceeds the bridge limit.")
|
||||
}
|
||||
if *request.MaximumOutputBytes < 1 || *request.MaximumOutputBytes > maximumOutputBytes {
|
||||
return failure("bridge", "output-limit", "The output bound is outside the bridge contract.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func compilePattern(pattern, flags string) (*regexp.Regexp, error) {
|
||||
var modifiers strings.Builder
|
||||
for _, flag := range "imsU" {
|
||||
if strings.ContainsRune(flags, flag) {
|
||||
modifiers.WriteRune(flag)
|
||||
}
|
||||
}
|
||||
if modifiers.Len() != 0 {
|
||||
pattern = "(?" + modifiers.String() + ")" + pattern
|
||||
}
|
||||
return regexp.Compile(pattern)
|
||||
}
|
||||
|
||||
func materializeMatch(indices []int, groupCount int) nativeMatch {
|
||||
record := nativeMatch{
|
||||
Span: [2]int{indices[0], indices[1]},
|
||||
Captures: make([]*[2]int, groupCount),
|
||||
}
|
||||
for group := 0; group < groupCount; group++ {
|
||||
start := indices[2+group*2]
|
||||
end := indices[3+group*2]
|
||||
if start >= 0 {
|
||||
span := [2]int{start, end}
|
||||
record.Captures[group] = &span
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func collectMatches(
|
||||
compiled *regexp.Regexp,
|
||||
subject string,
|
||||
global bool,
|
||||
maximumMatchCount int,
|
||||
maximumRows int,
|
||||
) ([][]int, bool) {
|
||||
rowsPerMatch := compiled.NumSubexp()
|
||||
if rowsPerMatch < 1 {
|
||||
rowsPerMatch = 1
|
||||
}
|
||||
allowed := maximumMatchCount
|
||||
if rowBound := maximumRows / rowsPerMatch; rowBound < allowed {
|
||||
allowed = rowBound
|
||||
}
|
||||
probeLimit := allowed + 1
|
||||
var indices [][]int
|
||||
if global {
|
||||
indices = compiled.FindAllStringSubmatchIndex(subject, probeLimit)
|
||||
} else if candidate := compiled.FindStringSubmatchIndex(subject); candidate != nil {
|
||||
indices = [][]int{candidate}
|
||||
}
|
||||
if len(indices) <= allowed {
|
||||
return indices, false
|
||||
}
|
||||
return indices[:allowed], true
|
||||
}
|
||||
|
||||
type boundedOutput struct {
|
||||
buffer bytes.Buffer
|
||||
maximum int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (output *boundedOutput) append(value string) {
|
||||
if output.truncated || value == "" {
|
||||
return
|
||||
}
|
||||
remaining := output.maximum - output.buffer.Len()
|
||||
if len(value) <= remaining {
|
||||
output.buffer.WriteString(value)
|
||||
return
|
||||
}
|
||||
end := remaining
|
||||
for end > 0 && !utf8.ValidString(value[:end]) {
|
||||
end--
|
||||
}
|
||||
output.buffer.WriteString(value[:end])
|
||||
output.truncated = true
|
||||
}
|
||||
|
||||
func extractReplacementReference(value string) (name string, number int, rest string, ok bool) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
braced := false
|
||||
if value[0] == '{' {
|
||||
braced = true
|
||||
value = value[1:]
|
||||
}
|
||||
index := 0
|
||||
for index < len(value) {
|
||||
character, size := utf8.DecodeRuneInString(value[index:])
|
||||
if !unicode.IsLetter(character) && !unicode.IsDigit(character) && character != '_' {
|
||||
break
|
||||
}
|
||||
index += size
|
||||
}
|
||||
if index == 0 {
|
||||
return
|
||||
}
|
||||
name = value[:index]
|
||||
if braced {
|
||||
if index >= len(value) || value[index] != '}' {
|
||||
return
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
number = 0
|
||||
for position := 0; position < len(name); position++ {
|
||||
if name[position] < '0' || name[position] > '9' || number >= 1e8 {
|
||||
number = -1
|
||||
break
|
||||
}
|
||||
number = number*10 + int(name[position]) - '0'
|
||||
}
|
||||
if name[0] == '0' && len(name) > 1 {
|
||||
number = -1
|
||||
}
|
||||
rest = value[index:]
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
func expandReplacementBounded(
|
||||
output *boundedOutput,
|
||||
compiled *regexp.Regexp,
|
||||
template, subject string,
|
||||
match []int,
|
||||
) {
|
||||
for len(template) > 0 && !output.truncated {
|
||||
before, after, found := strings.Cut(template, "$")
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
output.append(before)
|
||||
if output.truncated {
|
||||
return
|
||||
}
|
||||
template = after
|
||||
if template != "" && template[0] == '$' {
|
||||
output.append("$")
|
||||
template = template[1:]
|
||||
continue
|
||||
}
|
||||
name, number, rest, ok := extractReplacementReference(template)
|
||||
if !ok {
|
||||
output.append("$")
|
||||
continue
|
||||
}
|
||||
template = rest
|
||||
if number >= 0 {
|
||||
if 2*number+1 < len(match) && match[2*number] >= 0 {
|
||||
output.append(subject[match[2*number]:match[2*number+1]])
|
||||
}
|
||||
continue
|
||||
}
|
||||
for index, candidate := range compiled.SubexpNames() {
|
||||
if name == candidate && 2*index+1 < len(match) && match[2*index] >= 0 {
|
||||
output.append(subject[match[2*index]:match[2*index+1]])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
output.append(template)
|
||||
}
|
||||
|
||||
func replaceBounded(
|
||||
compiled *regexp.Regexp,
|
||||
subject, replacement string,
|
||||
indices [][]int,
|
||||
maximumBytes int,
|
||||
) (string, int, bool) {
|
||||
output := boundedOutput{maximum: maximumBytes}
|
||||
cursor := 0
|
||||
for _, match := range indices {
|
||||
output.append(subject[cursor:match[0]])
|
||||
expandReplacementBounded(&output, compiled, replacement, subject, match)
|
||||
cursor = match[1]
|
||||
}
|
||||
output.append(subject[cursor:])
|
||||
value := output.buffer.Bytes()
|
||||
return base64.StdEncoding.EncodeToString(value), len(value), output.truncated
|
||||
}
|
||||
|
||||
func run(encoded string) string {
|
||||
request, requestError := decodeRequest(encoded)
|
||||
if requestError != "" {
|
||||
return requestError
|
||||
}
|
||||
if validationError := validateRequest(request); validationError != "" {
|
||||
return validationError
|
||||
}
|
||||
|
||||
compiled, err := compilePattern(request.Pattern, request.Flags)
|
||||
if err != nil {
|
||||
return failure("compile", "compile-error", err)
|
||||
}
|
||||
groupCount := compiled.NumSubexp()
|
||||
if groupCount > maximumCaptureGroups {
|
||||
return failure(
|
||||
"compile",
|
||||
"capture-group-limit",
|
||||
fmt.Sprintf("The pattern declares %d capture groups; the bridge limit is %d.", groupCount, maximumCaptureGroups),
|
||||
)
|
||||
}
|
||||
|
||||
names := make([][2]any, 0)
|
||||
for number, name := range compiled.SubexpNames() {
|
||||
if number > 0 && name != "" {
|
||||
names = append(names, [2]any{number, name})
|
||||
}
|
||||
}
|
||||
sort.Slice(names, func(left, right int) bool {
|
||||
return names[left][0].(int) < names[right][0].(int)
|
||||
})
|
||||
|
||||
global := strings.ContainsRune(request.Flags, 'g')
|
||||
indices, resultsTruncated := collectMatches(
|
||||
compiled,
|
||||
request.Subject,
|
||||
global,
|
||||
request.MaximumMatches,
|
||||
request.MaximumCaptureRows,
|
||||
)
|
||||
records := make([]nativeMatch, len(indices))
|
||||
for index, match := range indices {
|
||||
records[index] = materializeMatch(match, groupCount)
|
||||
}
|
||||
result := bridgeSuccess{
|
||||
ABI: bridgeABIVersion,
|
||||
OK: true,
|
||||
GroupCount: groupCount,
|
||||
GroupNames: names,
|
||||
Matches: records,
|
||||
ResultsTruncated: resultsTruncated,
|
||||
}
|
||||
if request.Operation == "replace" {
|
||||
outputBase64, outputBytes, outputTruncated := replaceBounded(
|
||||
compiled,
|
||||
request.Subject,
|
||||
*request.Replacement,
|
||||
indices,
|
||||
*request.MaximumOutputBytes,
|
||||
)
|
||||
result.OutputBase64 = &outputBase64
|
||||
result.OutputBytes = &outputBytes
|
||||
result.OutputTruncated = &outputTruncated
|
||||
}
|
||||
return marshal(result)
|
||||
}
|
||||
|
||||
func selfTest() bool {
|
||||
if runtime.Version() != expectedGoVersion {
|
||||
return false
|
||||
}
|
||||
compiled, err := regexp.Compile(`(?P<word>😀+)`)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
match := compiled.FindStringSubmatchIndex("x😀😀")
|
||||
if len(match) != 4 || match[0] != 1 || match[1] != 9 ||
|
||||
match[2] != 1 || match[3] != 9 || compiled.SubexpNames()[1] != "word" {
|
||||
return false
|
||||
}
|
||||
for _, template := range []string{
|
||||
`${word}-$1-$$`,
|
||||
`$1x|${1}x`,
|
||||
`${missing}|$|${word`,
|
||||
`$01|${01}|$1234567890`,
|
||||
} {
|
||||
expected := string(compiled.ExpandString(nil, template, "x😀😀", match))
|
||||
bounded := boundedOutput{maximum: 4 * 1024}
|
||||
expandReplacementBounded(&bounded, compiled, template, "x😀😀", match)
|
||||
if bounded.truncated || bounded.buffer.String() != expected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
adversarial := boundedOutput{maximum: 5}
|
||||
expandReplacementBounded(
|
||||
&adversarial,
|
||||
compiled,
|
||||
strings.Repeat("$word", 32*1024),
|
||||
"x😀😀",
|
||||
match,
|
||||
)
|
||||
if !adversarial.truncated || adversarial.buffer.String() != "😀" {
|
||||
return false
|
||||
}
|
||||
empty := regexp.MustCompile("a*").FindAllStringIndex("baaab", -1)
|
||||
return len(empty) == 3 &&
|
||||
empty[0][0] == 0 && empty[0][1] == 0 &&
|
||||
empty[1][0] == 1 && empty[1][1] == 4 &&
|
||||
empty[2][0] == 5 && empty[2][1] == 5
|
||||
}
|
||||
|
||||
func identity() string {
|
||||
return marshal(identityEnvelope{
|
||||
ABI: bridgeABIVersion,
|
||||
OK: true,
|
||||
Identity: map[string]any{
|
||||
"bridge": "regex-tools-go-json",
|
||||
"bridgeVersion": bridgeABIVersion,
|
||||
"engine": "Go standard-library regexp",
|
||||
"engineVersion": "1.26.5",
|
||||
"goVersion": runtime.Version(),
|
||||
"offsetUnit": "utf8-byte",
|
||||
"package": "regexp",
|
||||
"selfTest": selfTest(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var retainedCallbacks []js.Func
|
||||
|
||||
func main() {
|
||||
identityCallback := js.FuncOf(func(_ js.Value, arguments []js.Value) any {
|
||||
if len(arguments) != 0 {
|
||||
return failure("bridge", "invalid-request", "The identity callback does not accept arguments.")
|
||||
}
|
||||
return identity()
|
||||
})
|
||||
runCallback := js.FuncOf(func(_ js.Value, arguments []js.Value) (result any) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
result = failure("bridge", "panic", "The Go bridge stopped an unexpected internal panic.")
|
||||
}
|
||||
}()
|
||||
if len(arguments) != 1 || arguments[0].Type() != js.TypeString {
|
||||
return failure("bridge", "invalid-request", "The run callback requires one JSON string.")
|
||||
}
|
||||
return run(arguments[0].String())
|
||||
})
|
||||
retainedCallbacks = []js.Func{identityCallback, runCallback}
|
||||
|
||||
installer := js.Global().Get(bridgeInstallerGlobalName)
|
||||
if installer.Type() != js.TypeFunction {
|
||||
panic("Regex Tools Go bridge installer is unavailable")
|
||||
}
|
||||
installer.Invoke(identityCallback, runCallback)
|
||||
select {}
|
||||
}
|
||||
Reference in New Issue
Block a user