Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
429 lines
12 KiB
Go
429 lines
12 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/grafana/sobek"
|
|
)
|
|
|
|
const (
|
|
CapabilityJSONClone = "json-clone@1"
|
|
CapabilitySubmitSSEDelta = "submit-sse-delta@1"
|
|
MaxJSONToolBytes = 1 << 20
|
|
maxJSONToolDepth = 32
|
|
maxJSONToolNodes = 32768
|
|
maxJSONChanges = 256
|
|
)
|
|
|
|
// HasCapability describes host APIs independently of a plugin's mutable globals.
|
|
func HasCapability(name string) bool {
|
|
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta
|
|
}
|
|
|
|
// JSONState owns a request-local JSON result. Appended strings stay in Go and
|
|
// are materialized only when the stream finishes, independently of JS runtimes.
|
|
type JSONState struct {
|
|
root *jsonStateNode
|
|
limit int
|
|
failed error
|
|
}
|
|
|
|
type jsonStateNode struct {
|
|
scalar any
|
|
object map[string]*jsonStateNode
|
|
array []*jsonStateNode
|
|
text *strings.Builder
|
|
bytes int
|
|
nodes int
|
|
}
|
|
|
|
type jsonStateBudget struct {
|
|
ctx context.Context
|
|
bytes int
|
|
nodes int
|
|
}
|
|
|
|
func (b *jsonStateBudget) spend(bytes, nodes int) error {
|
|
if err := b.ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if bytes > b.bytes || nodes > b.nodes {
|
|
return fmt.Errorf("JSON state exceeds size or node limit")
|
|
}
|
|
b.bytes -= bytes
|
|
b.nodes -= nodes
|
|
return nil
|
|
}
|
|
|
|
func newJSONStateNode(value any, depth int, budget *jsonStateBudget) (*jsonStateNode, error) {
|
|
if depth > maxJSONToolDepth {
|
|
return nil, fmt.Errorf("JSON state exceeds depth limit")
|
|
}
|
|
if js, ok := value.(sobek.Value); ok {
|
|
if sobek.IsUndefined(js) {
|
|
return nil, fmt.Errorf("undefined is not a JSON value")
|
|
}
|
|
if object, ok := js.(*sobek.Object); ok {
|
|
switch object.ClassName() {
|
|
case "Object":
|
|
keys := object.Keys()
|
|
if len(keys) > budget.nodes {
|
|
return nil, fmt.Errorf("JSON object exceeds node limit")
|
|
}
|
|
fields := make(map[string]any, len(keys))
|
|
for _, key := range keys {
|
|
fields[key] = object.Get(key)
|
|
}
|
|
value = fields
|
|
case "Array":
|
|
length := object.Get("length").ToInteger()
|
|
if length < 0 || length > int64(budget.nodes) {
|
|
return nil, fmt.Errorf("JSON array exceeds node limit")
|
|
}
|
|
items := make([]any, int(length))
|
|
for index := range items {
|
|
item := object.Get(strconv.Itoa(index))
|
|
if item == nil {
|
|
return nil, fmt.Errorf("sparse arrays are not JSON values")
|
|
}
|
|
items[index] = item
|
|
}
|
|
value = items
|
|
default:
|
|
return nil, fmt.Errorf("json.clone accepts only plain objects, arrays and JSON scalars")
|
|
}
|
|
} else {
|
|
value = js.Export()
|
|
}
|
|
}
|
|
beforeBytes, beforeNodes := budget.bytes, budget.nodes
|
|
if err := budget.spend(0, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
if typed == nil {
|
|
value = nil
|
|
}
|
|
case []any:
|
|
if typed == nil {
|
|
value = nil
|
|
}
|
|
}
|
|
node := &jsonStateNode{}
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
if len(typed) > budget.nodes {
|
|
return nil, fmt.Errorf("JSON object exceeds node limit")
|
|
}
|
|
if err := budget.spend(2, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
node.object = make(map[string]*jsonStateNode, len(typed))
|
|
for key, child := range typed {
|
|
if !utf8.ValidString(key) || len(key) > budget.bytes {
|
|
return nil, fmt.Errorf("invalid or oversized JSON object key")
|
|
}
|
|
encoded, err := common.Marshal(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cost := len(encoded) + 1
|
|
if len(node.object) != 0 {
|
|
cost++
|
|
}
|
|
if err = budget.spend(cost, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
node.object[key], err = newJSONStateNode(child, depth+1, budget)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
case []any:
|
|
if len(typed) > budget.nodes {
|
|
return nil, fmt.Errorf("JSON array exceeds node limit")
|
|
}
|
|
if err := budget.spend(2+max(0, len(typed)-1), 0); err != nil {
|
|
return nil, err
|
|
}
|
|
node.array = make([]*jsonStateNode, 0, len(typed))
|
|
for _, child := range typed {
|
|
item, err := newJSONStateNode(child, depth+1, budget)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
node.array = append(node.array, item)
|
|
}
|
|
default:
|
|
switch number := value.(type) {
|
|
case int:
|
|
value = float64(number)
|
|
case int64:
|
|
value = float64(number)
|
|
}
|
|
switch scalar := value.(type) {
|
|
case nil, bool:
|
|
case float64:
|
|
if math.IsNaN(scalar) || math.IsInf(scalar, 0) {
|
|
return nil, fmt.Errorf("JSON numbers must be finite")
|
|
}
|
|
case string:
|
|
if len(scalar) > budget.bytes {
|
|
return nil, fmt.Errorf("JSON string exceeds size limit")
|
|
}
|
|
if !utf8.ValidString(scalar) {
|
|
value = string([]rune(scalar))
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("value must contain only JSON objects, arrays and scalars")
|
|
}
|
|
encoded, err := common.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = budget.spend(len(encoded), 0); err != nil {
|
|
return nil, err
|
|
}
|
|
node.scalar = value
|
|
}
|
|
node.bytes, node.nodes = beforeBytes-budget.bytes, beforeNodes-budget.nodes
|
|
return node, nil
|
|
}
|
|
|
|
func NewJSONState(limit int) *JSONState {
|
|
return &JSONState{root: &jsonStateNode{bytes: 4, nodes: 1}, limit: max(0, min(limit, MaxJSONToolBytes))}
|
|
}
|
|
|
|
// Apply accepts a bounded batch of set, append and appendText operations.
|
|
// Failure invalidates the stream: callers must discard the state rather than
|
|
// continue after an error. No partially updated result may be published.
|
|
func (s *JSONState) Apply(ctx context.Context, changes any) (err error) {
|
|
if s.failed != nil {
|
|
return s.failed
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
s.failed = err
|
|
}
|
|
}()
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
items, ok := changes.([]any)
|
|
if !ok || len(items) > maxJSONChanges {
|
|
return fmt.Errorf("changes must be an array of at most %d operations", maxJSONChanges)
|
|
}
|
|
for _, item := range items {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
change, ok := item.(map[string]any)
|
|
if !ok || len(change) != 3 {
|
|
return fmt.Errorf("each change must contain only op, path and value")
|
|
}
|
|
op, _ := change["op"].(string)
|
|
path, ok := change["path"].([]any)
|
|
value, hasValue := change["value"]
|
|
if !ok || !hasValue || len(path) > maxJSONToolDepth {
|
|
return fmt.Errorf("change requires a JSON value and a path of at most %d segments", maxJSONToolDepth)
|
|
}
|
|
if op != "set" && op != "append" && op != "appendText" {
|
|
return fmt.Errorf("unsupported JSON change operation %q", op)
|
|
}
|
|
ancestors := make([]*jsonStateNode, 0, len(path))
|
|
target := s.root
|
|
var key string
|
|
var index int
|
|
for offset, segment := range path {
|
|
if target == nil {
|
|
return fmt.Errorf("JSON change parent does not exist")
|
|
}
|
|
ancestors = append(ancestors, target)
|
|
switch {
|
|
case target.object != nil:
|
|
key, ok = segment.(string)
|
|
if !ok || !utf8.ValidString(key) || len(key) > s.limit {
|
|
return fmt.Errorf("JSON object paths require valid string keys")
|
|
}
|
|
target = target.object[key]
|
|
case target.array != nil:
|
|
var number float64
|
|
switch typed := segment.(type) {
|
|
case int:
|
|
number = float64(typed)
|
|
case int64:
|
|
number = float64(typed)
|
|
case float64:
|
|
number = typed
|
|
default:
|
|
return fmt.Errorf("JSON array paths require integer indices")
|
|
}
|
|
if math.IsNaN(number) || number < 0 || number >= float64(len(target.array)) || math.Trunc(number) != number {
|
|
return fmt.Errorf("JSON array index is out of range")
|
|
}
|
|
index = int(number)
|
|
target = target.array[index]
|
|
default:
|
|
return fmt.Errorf("JSON change path traverses a scalar")
|
|
}
|
|
if target == nil && (offset != len(path)-1 || op != "set") {
|
|
return fmt.Errorf("JSON change target does not exist")
|
|
}
|
|
}
|
|
addedBytes, addedNodes := 0, 0
|
|
switch op {
|
|
case "set":
|
|
oldBytes, oldNodes := 0, 0
|
|
if target != nil {
|
|
oldBytes, oldNodes = target.bytes, target.nodes
|
|
} else {
|
|
encodedKey, err := common.Marshal(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
addedBytes = len(encodedKey) + 1
|
|
if len(ancestors[len(ancestors)-1].object) != 0 {
|
|
addedBytes++
|
|
}
|
|
}
|
|
budget := &jsonStateBudget{ctx: ctx, bytes: s.limit - s.root.bytes + oldBytes - addedBytes, nodes: maxJSONToolNodes - s.root.nodes + oldNodes}
|
|
next, err := newJSONStateNode(value, len(path), budget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
addedBytes += next.bytes - oldBytes
|
|
addedNodes = next.nodes - oldNodes
|
|
if len(ancestors) == 0 {
|
|
s.root = next
|
|
} else if parent := ancestors[len(ancestors)-1]; parent.object != nil {
|
|
parent.object[key] = next
|
|
} else {
|
|
parent.array[index] = next
|
|
}
|
|
case "append":
|
|
if target.array == nil {
|
|
return fmt.Errorf("append requires an array target")
|
|
}
|
|
if len(target.array) != 0 {
|
|
addedBytes = 1
|
|
}
|
|
budget := &jsonStateBudget{ctx: ctx, bytes: s.limit - s.root.bytes - addedBytes, nodes: maxJSONToolNodes - s.root.nodes}
|
|
next, err := newJSONStateNode(value, len(path)+1, budget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
addedBytes += next.bytes
|
|
addedNodes = next.nodes
|
|
target.array = append(target.array, next)
|
|
target.bytes += addedBytes
|
|
target.nodes += addedNodes
|
|
case "appendText":
|
|
previous, isText := target.scalar.(string)
|
|
text, ok := value.(string)
|
|
if !isText || !ok || len(text) > s.limit-s.root.bytes {
|
|
return fmt.Errorf("appendText requires strings within the state size limit")
|
|
}
|
|
if !utf8.ValidString(text) {
|
|
text = string([]rune(text))
|
|
}
|
|
encoded, err := common.Marshal(text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
addedBytes = len(encoded) - 2
|
|
if addedBytes > s.limit-s.root.bytes {
|
|
return fmt.Errorf("JSON state exceeds size limit")
|
|
}
|
|
if text != "" {
|
|
if target.text == nil {
|
|
target.text = &strings.Builder{}
|
|
target.text.WriteString(previous)
|
|
target.scalar = ""
|
|
}
|
|
target.text.WriteString(text)
|
|
}
|
|
target.bytes += addedBytes
|
|
}
|
|
for _, ancestor := range ancestors {
|
|
ancestor.bytes += addedBytes
|
|
ancestor.nodes += addedNodes
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Value returns the final ordinary JSON tree and verifies the incremental byte
|
|
// accounting against the configured codec before callers can persist it.
|
|
func (s *JSONState) Value() (any, error) {
|
|
if s.failed != nil {
|
|
return nil, s.failed
|
|
}
|
|
value := s.root.value()
|
|
encoded, err := common.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(encoded) != s.root.bytes || len(encoded) > s.limit {
|
|
return nil, fmt.Errorf("JSON state encoded size does not match its bounded representation")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (n *jsonStateNode) value() any {
|
|
if n.object != nil {
|
|
result := make(map[string]any, len(n.object))
|
|
for key, child := range n.object {
|
|
result[key] = child.value()
|
|
}
|
|
return result
|
|
}
|
|
if n.array != nil {
|
|
result := make([]any, len(n.array))
|
|
for index, child := range n.array {
|
|
result[index] = child.value()
|
|
}
|
|
return result
|
|
}
|
|
if n.text != nil {
|
|
return n.text.String()
|
|
}
|
|
return n.scalar
|
|
}
|
|
|
|
// Native JS containers are essential here: wrapping Go slices in ToValue would
|
|
// retain host-backed array mutation behavior instead of producing a JS clone.
|
|
func (n *jsonStateNode) jsValue(runtime *sobek.Runtime) (sobek.Value, error) {
|
|
if n.object != nil {
|
|
result := runtime.NewObject()
|
|
for key, child := range n.object {
|
|
value, err := child.jsValue(runtime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = result.DefineDataProperty(key, value, sobek.FLAG_TRUE, sobek.FLAG_TRUE, sobek.FLAG_TRUE); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
if n.array != nil {
|
|
items := make([]any, len(n.array))
|
|
for index, child := range n.array {
|
|
value, err := child.jsValue(runtime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items[index] = value
|
|
}
|
|
return runtime.NewArray(items...), nil
|
|
}
|
|
return runtime.ToValue(n.value()), nil
|
|
}
|