522 lines
15 KiB
Go
522 lines
15 KiB
Go
// 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 {}
|
|
}
|