feat(expr)!: add context.Context, an output budget and safety caps to evaluation
- commit
- e17d5613d156960f1d0b42dac2e4df8ec54d0a9d
- author
- brice · · 2026-10-12 15:12 UTC
- parent
- a1598a3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +652 -270
M expr/eval.go +162 -35
| @@ -3,15 +3,35 @@ | |||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 6 | 9 | "math" | |
| 7 | 10 | "sort" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 13 | + | // MaxResultBytes is the default budget of an evaluation: the total size, in | |
| 14 | + | // bytes, of the strings that format, join and toJSON build plus a nominal | |
| 15 | + | // cost for every element of the arrays that the * filter builds. The | |
| 16 | + | // reference implementation has no such limit; without one a few kilobytes of | |
| 17 | + | // expression can allocate gigabytes (format('{0}{0}{0}...', format(...))), | |
| 18 | + | // and arrays shared in a context can multiply under .* filters. | |
| 19 | + | const MaxResultBytes = 1 << 20 | |
| 20 | + | ||
| 21 | + | // filterElementCost is what one element of a filtered array costs against the | |
| 22 | + | // budget: the size of an interface value. | |
| 23 | + | const filterElementCost = 16 | |
| 24 | + | ||
| 25 | + | // maxEvalDepth bounds the recursion of the evaluator. The parser never | |
| 26 | + | // produces trees anywhere near it; it protects against hand-built ASTs. | |
| 27 | + | const maxEvalDepth = 1000 | |
| 28 | + | ||
| 10 | 29 | // Func implements a function callable from expressions. It receives the | |
| 11 | - | // evaluated arguments in order and returns the result or an error, which | |
| 12 | - | // [Evaluate] returns as is. Arity is not checked at call time: declare it in | |
| 13 | - | // a [FuncInfo] given to [Parse]. | |
| 14 | - | type Func func(args []Value) (Value, error) | |
| 30 | + | // evaluation's context, which it should honour if it blocks or loops, and the | |
| 31 | + | // evaluated arguments in order, and returns the result or an error. | |
| 32 | + | // [Evaluate] wraps the error in an [*EvalError] that unwraps to it. Arity is | |
| 33 | + | // not checked at call time: declare it in a [FuncInfo] given to [Parse]. | |
| 34 | + | type Func func(ctx context.Context, args []Value) (Value, error) | |
| 15 | 35 | ||
| 16 | 36 | // EvalContext is the environment an expression is evaluated in. | |
| 17 | 37 | type EvalContext struct { | |
| @@ -25,38 +45,55 @@ type EvalContext struct { | |||
| 25 | 45 | // case-insensitively. As in the reference implementation these take | |
| 26 | 46 | // precedence over the built-in functions. | |
| 27 | 47 | Funcs map[string]Func | |
| 48 | + | // MaxResultBytes is the output budget of the evaluation, see | |
| 49 | + | // [MaxResultBytes]. Zero means the default; a negative value removes the | |
| 50 | + | // limit. The budget is shared by the whole evaluation, not per call. | |
| 51 | + | MaxResultBytes int | |
| 28 | 52 | } | |
| 29 | 53 | ||
| 30 | 54 | // Evaluate computes the value of a parsed expression. | |
| 31 | 55 | // | |
| 32 | - | // Values of the context are never modified, and results may share them with | |
| 33 | - | // the context: treat the result as read-only. The error is an [*EvalError]. | |
| 34 | - | func Evaluate(node Node, ctx EvalContext) (Value, error) { | |
| 56 | + | // Evaluation stops with an error that wraps ctx.Err() when ctx is done, and | |
| 57 | + | // with an [*EvalError] when the output budget is exceeded. Values of the | |
| 58 | + | // context are never modified, and results may share them with the context: | |
| 59 | + | // treat the result as read-only. The error is an [*EvalError]. | |
| 60 | + | func Evaluate(ctx context.Context, node Node, ec EvalContext) (Value, error) { | |
| 61 | + | if ctx == nil { | |
| 62 | + | ctx = context.Background() | |
| 63 | + | } | |
| 35 | 64 | if node == nil { | |
| 36 | 65 | return nil, &EvalError{Message: "cannot evaluate an empty expression"} | |
| 37 | 66 | } | |
| 67 | + | if err := ctx.Err(); err != nil { | |
| 68 | + | return nil, cancelError(err) | |
| 69 | + | } | |
| 38 | 70 | ev := &evaluator{ | |
| 39 | - | contexts: make(map[string]Value, len(ctx.Contexts)), | |
| 40 | - | funcs: make(map[string]Func, len(ctx.Funcs)), | |
| 71 | + | ctx: ctx, | |
| 72 | + | contexts: make(map[string]Value, len(ec.Contexts)), | |
| 73 | + | funcs: make(map[string]Func, len(ec.Funcs)), | |
| 74 | + | limit: ec.MaxResultBytes, | |
| 75 | + | } | |
| 76 | + | if ev.limit == 0 { | |
| 77 | + | ev.limit = MaxResultBytes | |
| 41 | 78 | } | |
| 42 | - | names := make([]string, 0, len(ctx.Contexts)) | |
| 43 | - | for k := range ctx.Contexts { | |
| 79 | + | names := make([]string, 0, len(ec.Contexts)) | |
| 80 | + | for k := range ec.Contexts { | |
| 44 | 81 | names = append(names, k) | |
| 45 | 82 | } | |
| 46 | 83 | sort.Strings(names) | |
| 47 | 84 | for _, k := range names { | |
| 48 | 85 | if _, dup := ev.contexts[foldKey(k)]; !dup { | |
| 49 | - | ev.contexts[foldKey(k)] = ctx.Contexts[k] | |
| 86 | + | ev.contexts[foldKey(k)] = ec.Contexts[k] | |
| 50 | 87 | } | |
| 51 | 88 | } | |
| 52 | 89 | names = names[:0] | |
| 53 | - | for k := range ctx.Funcs { | |
| 90 | + | for k := range ec.Funcs { | |
| 54 | 91 | names = append(names, k) | |
| 55 | 92 | } | |
| 56 | 93 | sort.Strings(names) | |
| 57 | 94 | for _, k := range names { | |
| 58 | 95 | if _, dup := ev.funcs[foldKey(k)]; !dup { | |
| 59 | - | ev.funcs[foldKey(k)] = ctx.Funcs[k] | |
| 96 | + | ev.funcs[foldKey(k)] = ec.Funcs[k] | |
| 60 | 97 | } | |
| 61 | 98 | } | |
| 62 | 99 | v, err := ev.eval(node) | |
| @@ -66,15 +103,60 @@ func Evaluate(node Node, ctx EvalContext) (Value, error) { | |||
| 66 | 103 | return normalize(v), nil | |
| 67 | 104 | } | |
| 68 | 105 | ||
| 106 | + | func cancelError(err error) *EvalError { | |
| 107 | + | return &EvalError{Message: "evaluation canceled: " + err.Error(), Err: err} | |
| 108 | + | } | |
| 109 | + | ||
| 69 | 110 | type evaluator struct { | |
| 111 | + | ctx context.Context | |
| 70 | 112 | contexts map[string]Value | |
| 71 | 113 | funcs map[string]Func | |
| 114 | + | limit int // output budget; negative: unlimited | |
| 115 | + | used int | |
| 116 | + | depth int | |
| 117 | + | ticks int | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // tick is called in loops and on every node: it checks the context once in a | |
| 121 | + | // while. | |
| 122 | + | func (e *evaluator) tick() error { | |
| 123 | + | e.ticks++ | |
| 124 | + | if e.ticks&63 == 0 { | |
| 125 | + | if err := e.ctx.Err(); err != nil { | |
| 126 | + | return cancelError(err) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // charge spends n bytes of the output budget. | |
| 133 | + | func (e *evaluator) charge(n int) error { | |
| 134 | + | if e.limit < 0 { | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | if n > e.limit-e.used { // written to be overflow-proof | |
| 138 | + | return e.budgetError() | |
| 139 | + | } | |
| 140 | + | e.used += n | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (e *evaluator) budgetError() *EvalError { | |
| 145 | + | return &EvalError{Message: fmt.Sprintf("expression output exceeds the limit of %d bytes", e.limit)} | |
| 72 | 146 | } | |
| 73 | 147 | ||
| 74 | 148 | func (e *evaluator) eval(n Node) (Value, error) { | |
| 149 | + | if err := e.tick(); err != nil { | |
| 150 | + | return nil, err | |
| 151 | + | } | |
| 152 | + | e.depth++ | |
| 153 | + | defer func() { e.depth-- }() | |
| 154 | + | if e.depth > maxEvalDepth { | |
| 155 | + | return nil, &EvalError{Message: fmt.Sprintf("expression tree deeper than %d levels", maxEvalDepth)} | |
| 156 | + | } | |
| 75 | 157 | switch n := n.(type) { | |
| 76 | 158 | case *Literal: | |
| 77 | - | return n.Value, nil | |
| 159 | + | return normalize(n.Value), nil | |
| 78 | 160 | case *Unary: | |
| 79 | 161 | r, err := e.eval(n.Expr) | |
| 80 | 162 | if err != nil { | |
| @@ -143,10 +225,30 @@ func (e *evaluator) logical(l *Logical) (Value, error) { | |||
| 143 | 225 | break | |
| 144 | 226 | } | |
| 145 | 227 | } | |
| 228 | + | if result == nil { | |
| 229 | + | return Null{}, nil // a Logical without operands, possible only in a hand-built tree | |
| 230 | + | } | |
| 146 | 231 | return result, nil | |
| 147 | 232 | } | |
| 148 | 233 | ||
| 149 | 234 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | |
| 235 | + | name := foldKey(c.Name.Lexeme) | |
| 236 | + | ext, isExt := e.funcs[name] | |
| 237 | + | isExt = isExt && ext != nil | |
| 238 | + | var impl builtinFunc | |
| 239 | + | if !isExt { | |
| 240 | + | // Arity of the built-ins is checked by the parser, and again here | |
| 241 | + | // so that a hand-built tree cannot reach an implementation with the | |
| 242 | + | // wrong number of arguments. | |
| 243 | + | f, ok := builtinFuncs[name] | |
| 244 | + | if !ok { | |
| 245 | + | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |
| 246 | + | } | |
| 247 | + | if t, bad := arityError(name, f, len(c.Args)); bad { | |
| 248 | + | return nil, &EvalError{Message: t.description() + ": '" + c.Name.Lexeme + "'"} | |
| 249 | + | } | |
| 250 | + | impl = builtinImpls[name] | |
| 251 | + | } | |
| 150 | 252 | args := make([]Value, len(c.Args)) | |
| 151 | 253 | for i, a := range c.Args { | |
| 152 | 254 | v, err := e.eval(a) | |
| @@ -155,14 +257,18 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) { | |||
| 155 | 257 | } | |
| 156 | 258 | args[i] = v | |
| 157 | 259 | } | |
| 158 | - | name := foldKey(c.Name.Lexeme) | |
| 159 | - | if f, ok := e.funcs[name]; ok && f != nil { | |
| 160 | - | return f(args) | |
| 161 | - | } | |
| 162 | - | if f, ok := builtinImpls[name]; ok { | |
| 163 | - | return f(args) | |
| 260 | + | if isExt { | |
| 261 | + | v, err := ext(e.ctx, args) | |
| 262 | + | if err != nil { | |
| 263 | + | var ee *EvalError | |
| 264 | + | if errors.As(err, &ee) { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | return nil, &EvalError{Message: err.Error(), Err: err} | |
| 268 | + | } | |
| 269 | + | return v, nil | |
| 164 | 270 | } | |
| 165 | - | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |
| 271 | + | return impl(e, args) | |
| 166 | 272 | } | |
| 167 | 273 | ||
| 168 | 274 | // indexer is idxHelper of the reference: how an index value is read, as a | |
| @@ -221,11 +327,11 @@ func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) { | |||
| 221 | 327 | switch o := normalize(obj).(type) { | |
| 222 | 328 | case *Array: | |
| 223 | 329 | if o.filtered { | |
| 224 | - | return filteredArrayAccess(o, ix), nil | |
| 330 | + | return e.filteredArrayAccess(o, ix) | |
| 225 | 331 | } | |
| 226 | - | return arrayAccess(o, ix), nil | |
| 332 | + | return e.arrayAccess(o, ix) | |
| 227 | 333 | case *Dictionary: | |
| 228 | - | return objectAccess(o, ix), nil | |
| 334 | + | return e.objectAccess(o, ix) | |
| 229 | 335 | } | |
| 230 | 336 | if ix.star { | |
| 231 | 337 | return &Array{filtered: true}, nil | |
| @@ -235,49 +341,70 @@ func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) { | |||
| 235 | 341 | ||
| 236 | 342 | // filteredArrayAccess applies an index to each element of a filtered array | |
| 237 | 343 | // and collects the hits: "a.*.b" is the b of every element that has one. | |
| 238 | - | func filteredArrayAccess(fa *Array, ix indexer) *Array { | |
| 344 | + | func (e *evaluator) filteredArrayAccess(fa *Array, ix indexer) (Value, error) { | |
| 239 | 345 | result := &Array{filtered: true} | |
| 240 | 346 | for _, item := range fa.items { | |
| 347 | + | if err := e.tick(); err != nil { | |
| 348 | + | return nil, err | |
| 349 | + | } | |
| 241 | 350 | switch it := normalize(item).(type) { | |
| 242 | 351 | case *Dictionary: | |
| 243 | 352 | if ix.star { | |
| 353 | + | if err := e.charge(len(it.vals) * filterElementCost); err != nil { | |
| 354 | + | return nil, err | |
| 355 | + | } | |
| 244 | 356 | result.items = append(result.items, it.vals...) | |
| 245 | 357 | } else if ix.hasStr { | |
| 246 | 358 | if v, ok := it.Get(ix.str); ok { | |
| 359 | + | if err := e.charge(filterElementCost); err != nil { | |
| 360 | + | return nil, err | |
| 361 | + | } | |
| 247 | 362 | result.items = append(result.items, v) | |
| 248 | 363 | } | |
| 249 | 364 | } | |
| 250 | 365 | case *Array: | |
| 251 | 366 | if ix.star { | |
| 367 | + | if err := e.charge(len(it.items) * filterElementCost); err != nil { | |
| 368 | + | return nil, err | |
| 369 | + | } | |
| 252 | 370 | result.items = append(result.items, it.items...) | |
| 253 | 371 | } else if i, ok := ix.pos(len(it.items)); ok { | |
| 372 | + | if err := e.charge(filterElementCost); err != nil { | |
| 373 | + | return nil, err | |
| 374 | + | } | |
| 254 | 375 | result.items = append(result.items, it.items[i]) | |
| 255 | 376 | } | |
| 256 | 377 | } | |
| 257 | 378 | } | |
| 258 | - | return result | |
| 379 | + | return result, nil | |
| 259 | 380 | } | |
| 260 | 381 | ||
| 261 | - | func arrayAccess(a *Array, ix indexer) Value { | |
| 382 | + | func (e *evaluator) arrayAccess(a *Array, ix indexer) (Value, error) { | |
| 262 | 383 | if ix.star { | |
| 263 | - | return &Array{items: append([]Value(nil), a.items...), filtered: true} | |
| 384 | + | if err := e.charge(len(a.items) * filterElementCost); err != nil { | |
| 385 | + | return nil, err | |
| 386 | + | } | |
| 387 | + | return &Array{items: append([]Value(nil), a.items...), filtered: true}, nil | |
| 264 | 388 | } | |
| 265 | 389 | if i, ok := ix.pos(len(a.items)); ok { | |
| 266 | - | return normalize(a.items[i]) | |
| 390 | + | return normalize(a.items[i]), nil | |
| 267 | 391 | } | |
| 268 | - | return Null{} | |
| 392 | + | return Null{}, nil | |
| 269 | 393 | } | |
| 270 | 394 | ||
| 271 | - | func objectAccess(d *Dictionary, ix indexer) Value { | |
| 395 | + | func (e *evaluator) objectAccess(d *Dictionary, ix indexer) (Value, error) { | |
| 272 | 396 | if ix.star { | |
| 273 | - | return &Array{items: append([]Value(nil), d.vals...), filtered: true} | |
| 397 | + | if err := e.charge(len(d.vals) * filterElementCost); err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | return &Array{items: append([]Value(nil), d.vals...), filtered: true}, nil | |
| 274 | 401 | } | |
| 275 | 402 | if ix.hasStr { | |
| 276 | 403 | if v, ok := d.Get(ix.str); ok { | |
| 277 | - | return v | |
| 404 | + | return v, nil | |
| 278 | 405 | } | |
| 279 | 406 | } | |
| 280 | - | return Null{} | |
| 407 | + | return Null{}, nil | |
| 281 | 408 | } | |
| 282 | 409 | ||
| 283 | 410 | // coerceTypes converts two values of different kinds to a common kind the | |
| @@ -3,15 +3,35 @@ | |||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 6 | "math" | 9 | "math" | ||
| 7 | "sort" | 10 | "sort" | ||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 13 | + | // MaxResultBytes is the default budget of an evaluation: the total size, in | |||
| 14 | + | // bytes, of the strings that format, join and toJSON build plus a nominal | |||
| 15 | + | // cost for every element of the arrays that the * filter builds. The | |||
| 16 | + | // reference implementation has no such limit; without one a few kilobytes of | |||
| 17 | + | // expression can allocate gigabytes (format('{0}{0}{0}...', format(...))), | |||
| 18 | + | // and arrays shared in a context can multiply under .* filters. | |||
| 19 | + | const MaxResultBytes = 1 << 20 | |||
| 20 | + | ||||
| 21 | + | // filterElementCost is what one element of a filtered array costs against the | |||
| 22 | + | // budget: the size of an interface value. | |||
| 23 | + | const filterElementCost = 16 | |||
| 24 | + | ||||
| 25 | + | // maxEvalDepth bounds the recursion of the evaluator. The parser never | |||
| 26 | + | // produces trees anywhere near it; it protects against hand-built ASTs. | |||
| 27 | + | const maxEvalDepth = 1000 | |||
| 28 | + | ||||
| 10 | // Func implements a function callable from expressions. It receives the | 29 | // Func implements a function callable from expressions. It receives the | ||
| 11 | - | // evaluated arguments in order and returns the result or an error, which | 30 | + | // evaluation's context, which it should honour if it blocks or loops, and the |
| 12 | - | // [Evaluate] returns as is. Arity is not checked at call time: declare it in | 31 | + | // evaluated arguments in order, and returns the result or an error. |
| 13 | - | // a [FuncInfo] given to [Parse]. | 32 | + | // [Evaluate] wraps the error in an [*EvalError] that unwraps to it. Arity is |
| 14 | - | type Func func(args []Value) (Value, error) | 33 | + | // not checked at call time: declare it in a [FuncInfo] given to [Parse]. |
| 34 | + | type Func func(ctx context.Context, args []Value) (Value, error) | |||
| 15 | 35 | ||||
| 16 | // EvalContext is the environment an expression is evaluated in. | 36 | // EvalContext is the environment an expression is evaluated in. | ||
| 17 | type EvalContext struct { | 37 | type EvalContext struct { | ||
| @@ -25,38 +45,55 @@ type EvalContext struct { | |||||
| 25 | // case-insensitively. As in the reference implementation these take | 45 | // case-insensitively. As in the reference implementation these take | ||
| 26 | // precedence over the built-in functions. | 46 | // precedence over the built-in functions. | ||
| 27 | Funcs map[string]Func | 47 | Funcs map[string]Func | ||
| 48 | + | // MaxResultBytes is the output budget of the evaluation, see | |||
| 49 | + | // [MaxResultBytes]. Zero means the default; a negative value removes the | |||
| 50 | + | // limit. The budget is shared by the whole evaluation, not per call. | |||
| 51 | + | MaxResultBytes int | |||
| 28 | } | 52 | } | ||
| 29 | 53 | ||||
| 30 | // Evaluate computes the value of a parsed expression. | 54 | // Evaluate computes the value of a parsed expression. | ||
| 31 | // | 55 | // | ||
| 32 | - | // Values of the context are never modified, and results may share them with | 56 | + | // Evaluation stops with an error that wraps ctx.Err() when ctx is done, and |
| 33 | - | // the context: treat the result as read-only. The error is an [*EvalError]. | 57 | + | // with an [*EvalError] when the output budget is exceeded. Values of the |
| 34 | - | func Evaluate(node Node, ctx EvalContext) (Value, error) { | 58 | + | // context are never modified, and results may share them with the context: |
| 59 | + | // treat the result as read-only. The error is an [*EvalError]. | |||
| 60 | + | func Evaluate(ctx context.Context, node Node, ec EvalContext) (Value, error) { | |||
| 61 | + | if ctx == nil { | |||
| 62 | + | ctx = context.Background() | |||
| 63 | + | } | |||
| 35 | if node == nil { | 64 | if node == nil { | ||
| 36 | return nil, &EvalError{Message: "cannot evaluate an empty expression"} | 65 | return nil, &EvalError{Message: "cannot evaluate an empty expression"} | ||
| 37 | } | 66 | } | ||
| 67 | + | if err := ctx.Err(); err != nil { | |||
| 68 | + | return nil, cancelError(err) | |||
| 69 | + | } | |||
| 38 | ev := &evaluator{ | 70 | ev := &evaluator{ | ||
| 39 | - | contexts: make(map[string]Value, len(ctx.Contexts)), | 71 | + | ctx: ctx, |
| 40 | - | funcs: make(map[string]Func, len(ctx.Funcs)), | 72 | + | contexts: make(map[string]Value, len(ec.Contexts)), |
| 73 | + | funcs: make(map[string]Func, len(ec.Funcs)), | |||
| 74 | + | limit: ec.MaxResultBytes, | |||
| 75 | + | } | |||
| 76 | + | if ev.limit == 0 { | |||
| 77 | + | ev.limit = MaxResultBytes | |||
| 41 | } | 78 | } | ||
| 42 | - | names := make([]string, 0, len(ctx.Contexts)) | 79 | + | names := make([]string, 0, len(ec.Contexts)) |
| 43 | - | for k := range ctx.Contexts { | 80 | + | for k := range ec.Contexts { |
| 44 | names = append(names, k) | 81 | names = append(names, k) | ||
| 45 | } | 82 | } | ||
| 46 | sort.Strings(names) | 83 | sort.Strings(names) | ||
| 47 | for _, k := range names { | 84 | for _, k := range names { | ||
| 48 | if _, dup := ev.contexts[foldKey(k)]; !dup { | 85 | if _, dup := ev.contexts[foldKey(k)]; !dup { | ||
| 49 | - | ev.contexts[foldKey(k)] = ctx.Contexts[k] | 86 | + | ev.contexts[foldKey(k)] = ec.Contexts[k] |
| 50 | } | 87 | } | ||
| 51 | } | 88 | } | ||
| 52 | names = names[:0] | 89 | names = names[:0] | ||
| 53 | - | for k := range ctx.Funcs { | 90 | + | for k := range ec.Funcs { |
| 54 | names = append(names, k) | 91 | names = append(names, k) | ||
| 55 | } | 92 | } | ||
| 56 | sort.Strings(names) | 93 | sort.Strings(names) | ||
| 57 | for _, k := range names { | 94 | for _, k := range names { | ||
| 58 | if _, dup := ev.funcs[foldKey(k)]; !dup { | 95 | if _, dup := ev.funcs[foldKey(k)]; !dup { | ||
| 59 | - | ev.funcs[foldKey(k)] = ctx.Funcs[k] | 96 | + | ev.funcs[foldKey(k)] = ec.Funcs[k] |
| 60 | } | 97 | } | ||
| 61 | } | 98 | } | ||
| 62 | v, err := ev.eval(node) | 99 | v, err := ev.eval(node) | ||
| @@ -66,15 +103,60 @@ func Evaluate(node Node, ctx EvalContext) (Value, error) { | |||||
| 66 | return normalize(v), nil | 103 | return normalize(v), nil | ||
| 67 | } | 104 | } | ||
| 68 | 105 | ||||
| 106 | + | func cancelError(err error) *EvalError { | |||
| 107 | + | return &EvalError{Message: "evaluation canceled: " + err.Error(), Err: err} | |||
| 108 | + | } | |||
| 109 | + | ||||
| 69 | type evaluator struct { | 110 | type evaluator struct { | ||
| 111 | + | ctx context.Context | |||
| 70 | contexts map[string]Value | 112 | contexts map[string]Value | ||
| 71 | funcs map[string]Func | 113 | funcs map[string]Func | ||
| 114 | + | limit int // output budget; negative: unlimited | |||
| 115 | + | used int | |||
| 116 | + | depth int | |||
| 117 | + | ticks int | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // tick is called in loops and on every node: it checks the context once in a | |||
| 121 | + | // while. | |||
| 122 | + | func (e *evaluator) tick() error { | |||
| 123 | + | e.ticks++ | |||
| 124 | + | if e.ticks&63 == 0 { | |||
| 125 | + | if err := e.ctx.Err(); err != nil { | |||
| 126 | + | return cancelError(err) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | return nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // charge spends n bytes of the output budget. | |||
| 133 | + | func (e *evaluator) charge(n int) error { | |||
| 134 | + | if e.limit < 0 { | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | if n > e.limit-e.used { // written to be overflow-proof | |||
| 138 | + | return e.budgetError() | |||
| 139 | + | } | |||
| 140 | + | e.used += n | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (e *evaluator) budgetError() *EvalError { | |||
| 145 | + | return &EvalError{Message: fmt.Sprintf("expression output exceeds the limit of %d bytes", e.limit)} | |||
| 72 | } | 146 | } | ||
| 73 | 147 | ||||
| 74 | func (e *evaluator) eval(n Node) (Value, error) { | 148 | func (e *evaluator) eval(n Node) (Value, error) { | ||
| 149 | + | if err := e.tick(); err != nil { | |||
| 150 | + | return nil, err | |||
| 151 | + | } | |||
| 152 | + | e.depth++ | |||
| 153 | + | defer func() { e.depth-- }() | |||
| 154 | + | if e.depth > maxEvalDepth { | |||
| 155 | + | return nil, &EvalError{Message: fmt.Sprintf("expression tree deeper than %d levels", maxEvalDepth)} | |||
| 156 | + | } | |||
| 75 | switch n := n.(type) { | 157 | switch n := n.(type) { | ||
| 76 | case *Literal: | 158 | case *Literal: | ||
| 77 | - | return n.Value, nil | 159 | + | return normalize(n.Value), nil |
| 78 | case *Unary: | 160 | case *Unary: | ||
| 79 | r, err := e.eval(n.Expr) | 161 | r, err := e.eval(n.Expr) | ||
| 80 | if err != nil { | 162 | if err != nil { | ||
| @@ -143,10 +225,30 @@ func (e *evaluator) logical(l *Logical) (Value, error) { | |||||
| 143 | break | 225 | break | ||
| 144 | } | 226 | } | ||
| 145 | } | 227 | } | ||
| 228 | + | if result == nil { | |||
| 229 | + | return Null{}, nil // a Logical without operands, possible only in a hand-built tree | |||
| 230 | + | } | |||
| 146 | return result, nil | 231 | return result, nil | ||
| 147 | } | 232 | } | ||
| 148 | 233 | ||||
| 149 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | 234 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | ||
| 235 | + | name := foldKey(c.Name.Lexeme) | |||
| 236 | + | ext, isExt := e.funcs[name] | |||
| 237 | + | isExt = isExt && ext != nil | |||
| 238 | + | var impl builtinFunc | |||
| 239 | + | if !isExt { | |||
| 240 | + | // Arity of the built-ins is checked by the parser, and again here | |||
| 241 | + | // so that a hand-built tree cannot reach an implementation with the | |||
| 242 | + | // wrong number of arguments. | |||
| 243 | + | f, ok := builtinFuncs[name] | |||
| 244 | + | if !ok { | |||
| 245 | + | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |||
| 246 | + | } | |||
| 247 | + | if t, bad := arityError(name, f, len(c.Args)); bad { | |||
| 248 | + | return nil, &EvalError{Message: t.description() + ": '" + c.Name.Lexeme + "'"} | |||
| 249 | + | } | |||
| 250 | + | impl = builtinImpls[name] | |||
| 251 | + | } | |||
| 150 | args := make([]Value, len(c.Args)) | 252 | args := make([]Value, len(c.Args)) | ||
| 151 | for i, a := range c.Args { | 253 | for i, a := range c.Args { | ||
| 152 | v, err := e.eval(a) | 254 | v, err := e.eval(a) | ||
| @@ -155,14 +257,18 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) { | |||||
| 155 | } | 257 | } | ||
| 156 | args[i] = v | 258 | args[i] = v | ||
| 157 | } | 259 | } | ||
| 158 | - | name := foldKey(c.Name.Lexeme) | 260 | + | if isExt { |
| 159 | - | if f, ok := e.funcs[name]; ok && f != nil { | 261 | + | v, err := ext(e.ctx, args) |
| 160 | - | return f(args) | 262 | + | if err != nil { |
| 161 | - | } | 263 | + | var ee *EvalError |
| 162 | - | if f, ok := builtinImpls[name]; ok { | 264 | + | if errors.As(err, &ee) { |
| 163 | - | return f(args) | 265 | + | return nil, err |
| 266 | + | } | |||
| 267 | + | return nil, &EvalError{Message: err.Error(), Err: err} | |||
| 268 | + | } | |||
| 269 | + | return v, nil | |||
| 164 | } | 270 | } | ||
| 165 | - | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | 271 | + | return impl(e, args) |
| 166 | } | 272 | } | ||
| 167 | 273 | ||||
| 168 | // indexer is idxHelper of the reference: how an index value is read, as a | 274 | // indexer is idxHelper of the reference: how an index value is read, as a | ||
| @@ -221,11 +327,11 @@ func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) { | |||||
| 221 | switch o := normalize(obj).(type) { | 327 | switch o := normalize(obj).(type) { | ||
| 222 | case *Array: | 328 | case *Array: | ||
| 223 | if o.filtered { | 329 | if o.filtered { | ||
| 224 | - | return filteredArrayAccess(o, ix), nil | 330 | + | return e.filteredArrayAccess(o, ix) |
| 225 | } | 331 | } | ||
| 226 | - | return arrayAccess(o, ix), nil | 332 | + | return e.arrayAccess(o, ix) |
| 227 | case *Dictionary: | 333 | case *Dictionary: | ||
| 228 | - | return objectAccess(o, ix), nil | 334 | + | return e.objectAccess(o, ix) |
| 229 | } | 335 | } | ||
| 230 | if ix.star { | 336 | if ix.star { | ||
| 231 | return &Array{filtered: true}, nil | 337 | return &Array{filtered: true}, nil | ||
| @@ -235,49 +341,70 @@ func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) { | |||||
| 235 | 341 | ||||
| 236 | // filteredArrayAccess applies an index to each element of a filtered array | 342 | // filteredArrayAccess applies an index to each element of a filtered array | ||
| 237 | // and collects the hits: "a.*.b" is the b of every element that has one. | 343 | // and collects the hits: "a.*.b" is the b of every element that has one. | ||
| 238 | - | func filteredArrayAccess(fa *Array, ix indexer) *Array { | 344 | + | func (e *evaluator) filteredArrayAccess(fa *Array, ix indexer) (Value, error) { |
| 239 | result := &Array{filtered: true} | 345 | result := &Array{filtered: true} | ||
| 240 | for _, item := range fa.items { | 346 | for _, item := range fa.items { | ||
| 347 | + | if err := e.tick(); err != nil { | |||
| 348 | + | return nil, err | |||
| 349 | + | } | |||
| 241 | switch it := normalize(item).(type) { | 350 | switch it := normalize(item).(type) { | ||
| 242 | case *Dictionary: | 351 | case *Dictionary: | ||
| 243 | if ix.star { | 352 | if ix.star { | ||
| 353 | + | if err := e.charge(len(it.vals) * filterElementCost); err != nil { | |||
| 354 | + | return nil, err | |||
| 355 | + | } | |||
| 244 | result.items = append(result.items, it.vals...) | 356 | result.items = append(result.items, it.vals...) | ||
| 245 | } else if ix.hasStr { | 357 | } else if ix.hasStr { | ||
| 246 | if v, ok := it.Get(ix.str); ok { | 358 | if v, ok := it.Get(ix.str); ok { | ||
| 359 | + | if err := e.charge(filterElementCost); err != nil { | |||
| 360 | + | return nil, err | |||
| 361 | + | } | |||
| 247 | result.items = append(result.items, v) | 362 | result.items = append(result.items, v) | ||
| 248 | } | 363 | } | ||
| 249 | } | 364 | } | ||
| 250 | case *Array: | 365 | case *Array: | ||
| 251 | if ix.star { | 366 | if ix.star { | ||
| 367 | + | if err := e.charge(len(it.items) * filterElementCost); err != nil { | |||
| 368 | + | return nil, err | |||
| 369 | + | } | |||
| 252 | result.items = append(result.items, it.items...) | 370 | result.items = append(result.items, it.items...) | ||
| 253 | } else if i, ok := ix.pos(len(it.items)); ok { | 371 | } else if i, ok := ix.pos(len(it.items)); ok { | ||
| 372 | + | if err := e.charge(filterElementCost); err != nil { | |||
| 373 | + | return nil, err | |||
| 374 | + | } | |||
| 254 | result.items = append(result.items, it.items[i]) | 375 | result.items = append(result.items, it.items[i]) | ||
| 255 | } | 376 | } | ||
| 256 | } | 377 | } | ||
| 257 | } | 378 | } | ||
| 258 | - | return result | 379 | + | return result, nil |
| 259 | } | 380 | } | ||
| 260 | 381 | ||||
| 261 | - | func arrayAccess(a *Array, ix indexer) Value { | 382 | + | func (e *evaluator) arrayAccess(a *Array, ix indexer) (Value, error) { |
| 262 | if ix.star { | 383 | if ix.star { | ||
| 263 | - | return &Array{items: append([]Value(nil), a.items...), filtered: true} | 384 | + | if err := e.charge(len(a.items) * filterElementCost); err != nil { |
| 385 | + | return nil, err | |||
| 386 | + | } | |||
| 387 | + | return &Array{items: append([]Value(nil), a.items...), filtered: true}, nil | |||
| 264 | } | 388 | } | ||
| 265 | if i, ok := ix.pos(len(a.items)); ok { | 389 | if i, ok := ix.pos(len(a.items)); ok { | ||
| 266 | - | return normalize(a.items[i]) | 390 | + | return normalize(a.items[i]), nil |
| 267 | } | 391 | } | ||
| 268 | - | return Null{} | 392 | + | return Null{}, nil |
| 269 | } | 393 | } | ||
| 270 | 394 | ||||
| 271 | - | func objectAccess(d *Dictionary, ix indexer) Value { | 395 | + | func (e *evaluator) objectAccess(d *Dictionary, ix indexer) (Value, error) { |
| 272 | if ix.star { | 396 | if ix.star { | ||
| 273 | - | return &Array{items: append([]Value(nil), d.vals...), filtered: true} | 397 | + | if err := e.charge(len(d.vals) * filterElementCost); err != nil { |
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | return &Array{items: append([]Value(nil), d.vals...), filtered: true}, nil | |||
| 274 | } | 401 | } | ||
| 275 | if ix.hasStr { | 402 | if ix.hasStr { | ||
| 276 | if v, ok := d.Get(ix.str); ok { | 403 | if v, ok := d.Get(ix.str); ok { | ||
| 277 | - | return v | 404 | + | return v, nil |
| 278 | } | 405 | } | ||
| 279 | } | 406 | } | ||
| 280 | - | return Null{} | 407 | + | return Null{}, nil |
| 281 | } | 408 | } | ||
| 282 | 409 | ||||
| 283 | // coerceTypes converts two values of different kinds to a common kind the | 410 | // coerceTypes converts two values of different kinds to a common kind the | ||
M expr/eval_test.go +14 -13
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "math" | |
| 8 | 9 | "strings" | |
| @@ -539,7 +540,7 @@ func TestEmptyExpression(t *testing.T) { | |||
| 539 | 540 | t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err) | |
| 540 | 541 | } | |
| 541 | 542 | } | |
| 542 | - | if _, err := Evaluate(nil, EvalContext{}); err == nil { | |
| 543 | + | if _, err := Evaluate(context.Background(), nil, EvalContext{}); err == nil { | |
| 543 | 544 | t.Error("Evaluate(nil) succeeded") | |
| 544 | 545 | } | |
| 545 | 546 | } | |
| @@ -686,21 +687,21 @@ func TestExtensionFunctions(t *testing.T) { | |||
| 686 | 687 | ctx := EvalContext{ | |
| 687 | 688 | Contexts: map[string]Value{"G": String("v")}, | |
| 688 | 689 | Funcs: map[string]Func{ | |
| 689 | - | "HashFiles": func(args []Value) (Value, error) { | |
| 690 | + | "HashFiles": func(_ context.Context, args []Value) (Value, error) { | |
| 690 | 691 | calls++ | |
| 691 | 692 | return String("h:" + CoerceString(args[0])), nil | |
| 692 | 693 | }, | |
| 693 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | |
| 694 | - | "boom": func([]Value) (Value, error) { return nil, errors.New("kaput") }, | |
| 694 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | |
| 695 | + | "boom": func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") }, | |
| 695 | 696 | // An extra function replaces a built-in of the same name. | |
| 696 | - | "contains": func([]Value) (Value, error) { return String("mine"), nil }, | |
| 697 | + | "contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil }, | |
| 697 | 698 | }, | |
| 698 | 699 | } | |
| 699 | 700 | node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs) | |
| 700 | 701 | if err != nil { | |
| 701 | 702 | t.Fatal(err) | |
| 702 | 703 | } | |
| 703 | - | v, err := Evaluate(node, ctx) | |
| 704 | + | v, err := Evaluate(context.Background(), node, ctx) | |
| 704 | 705 | if err != nil || CoerceString(v) != "mine" || calls != 1 { | |
| 705 | 706 | t.Errorf("got %v, %v, %d calls", v, err, calls) | |
| 706 | 707 | } | |
| @@ -708,12 +709,12 @@ func TestExtensionFunctions(t *testing.T) { | |||
| 708 | 709 | if err != nil { | |
| 709 | 710 | t.Fatal(err) | |
| 710 | 711 | } | |
| 711 | - | if _, err = Evaluate(node, ctx); err == nil || err.Error() != "kaput" { | |
| 712 | + | if _, err = Evaluate(context.Background(), node, ctx); err == nil || err.Error() != "kaput" { | |
| 712 | 713 | t.Errorf("function error not propagated: %v", err) | |
| 713 | 714 | } | |
| 714 | 715 | // A declared function without an implementation is an evaluation error. | |
| 715 | 716 | node, _ = Parse("success()", nil, funcs) | |
| 716 | - | if _, err = Evaluate(node, EvalContext{}); err == nil { | |
| 717 | + | if _, err = Evaluate(context.Background(), node, EvalContext{}); err == nil { | |
| 717 | 718 | t.Error("calling an unimplemented function succeeded") | |
| 718 | 719 | } | |
| 719 | 720 | } | |
| @@ -726,13 +727,13 @@ func TestContextNames(t *testing.T) { | |||
| 726 | 727 | for _, key := range []string{"github", "GITHUB", "GitHub"} { | |
| 727 | 728 | d := NewDictionary() | |
| 728 | 729 | d.Add("ref", String("main")) | |
| 729 | - | v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{key: d}}) | |
| 730 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{key: d}}) | |
| 730 | 731 | if err != nil || CoerceString(v) != "main" { | |
| 731 | 732 | t.Errorf("context key %q: %v, %v", key, v, err) | |
| 732 | 733 | } | |
| 733 | 734 | } | |
| 734 | 735 | // A declared but absent context is null. | |
| 735 | - | v, err := Evaluate(node, EvalContext{}) | |
| 736 | + | v, err := Evaluate(context.Background(), node, EvalContext{}) | |
| 736 | 737 | if err != nil || v.Kind() != KindNull { | |
| 737 | 738 | t.Errorf("absent context: %v, %v", v, err) | |
| 738 | 739 | } | |
| @@ -742,14 +743,14 @@ func TestContextNames(t *testing.T) { | |||
| 742 | 743 | } | |
| 743 | 744 | // A nil Value in a context behaves as null. | |
| 744 | 745 | node, _ = Parse("github.a.b", []string{"github"}, nil) | |
| 745 | - | if v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull { | |
| 746 | + | if v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull { | |
| 746 | 747 | t.Errorf("nil context value: %v, %v", v, err) | |
| 747 | 748 | } | |
| 748 | 749 | var nilDict *Dictionary | |
| 749 | 750 | var nilArr *Array | |
| 750 | 751 | node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil) | |
| 751 | 752 | for _, bad := range []Value{nilDict, nilArr} { | |
| 752 | - | if _, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil { | |
| 753 | + | if _, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil { | |
| 753 | 754 | t.Errorf("typed nil %T: %v", bad, err) | |
| 754 | 755 | } | |
| 755 | 756 | } | |
| @@ -766,7 +767,7 @@ func TestEvaluateDoesNotMutateContext(t *testing.T) { | |||
| 766 | 767 | if err != nil { | |
| 767 | 768 | t.Fatal(err) | |
| 768 | 769 | } | |
| 769 | - | v, err := Evaluate(node, EvalContext{Contexts: ctxs}) | |
| 770 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: ctxs}) | |
| 770 | 771 | if err != nil { | |
| 771 | 772 | t.Fatal(err) | |
| 772 | 773 | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "math" | 8 | "math" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -539,7 +540,7 @@ func TestEmptyExpression(t *testing.T) { | |||||
| 539 | t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err) | 540 | t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err) | ||
| 540 | } | 541 | } | ||
| 541 | } | 542 | } | ||
| 542 | - | if _, err := Evaluate(nil, EvalContext{}); err == nil { | 543 | + | if _, err := Evaluate(context.Background(), nil, EvalContext{}); err == nil { |
| 543 | t.Error("Evaluate(nil) succeeded") | 544 | t.Error("Evaluate(nil) succeeded") | ||
| 544 | } | 545 | } | ||
| 545 | } | 546 | } | ||
| @@ -686,21 +687,21 @@ func TestExtensionFunctions(t *testing.T) { | |||||
| 686 | ctx := EvalContext{ | 687 | ctx := EvalContext{ | ||
| 687 | Contexts: map[string]Value{"G": String("v")}, | 688 | Contexts: map[string]Value{"G": String("v")}, | ||
| 688 | Funcs: map[string]Func{ | 689 | Funcs: map[string]Func{ | ||
| 689 | - | "HashFiles": func(args []Value) (Value, error) { | 690 | + | "HashFiles": func(_ context.Context, args []Value) (Value, error) { |
| 690 | calls++ | 691 | calls++ | ||
| 691 | return String("h:" + CoerceString(args[0])), nil | 692 | return String("h:" + CoerceString(args[0])), nil | ||
| 692 | }, | 693 | }, | ||
| 693 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | 694 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, |
| 694 | - | "boom": func([]Value) (Value, error) { return nil, errors.New("kaput") }, | 695 | + | "boom": func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") }, |
| 695 | // An extra function replaces a built-in of the same name. | 696 | // An extra function replaces a built-in of the same name. | ||
| 696 | - | "contains": func([]Value) (Value, error) { return String("mine"), nil }, | 697 | + | "contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil }, |
| 697 | }, | 698 | }, | ||
| 698 | } | 699 | } | ||
| 699 | node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs) | 700 | node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs) | ||
| 700 | if err != nil { | 701 | if err != nil { | ||
| 701 | t.Fatal(err) | 702 | t.Fatal(err) | ||
| 702 | } | 703 | } | ||
| 703 | - | v, err := Evaluate(node, ctx) | 704 | + | v, err := Evaluate(context.Background(), node, ctx) |
| 704 | if err != nil || CoerceString(v) != "mine" || calls != 1 { | 705 | if err != nil || CoerceString(v) != "mine" || calls != 1 { | ||
| 705 | t.Errorf("got %v, %v, %d calls", v, err, calls) | 706 | t.Errorf("got %v, %v, %d calls", v, err, calls) | ||
| 706 | } | 707 | } | ||
| @@ -708,12 +709,12 @@ func TestExtensionFunctions(t *testing.T) { | |||||
| 708 | if err != nil { | 709 | if err != nil { | ||
| 709 | t.Fatal(err) | 710 | t.Fatal(err) | ||
| 710 | } | 711 | } | ||
| 711 | - | if _, err = Evaluate(node, ctx); err == nil || err.Error() != "kaput" { | 712 | + | if _, err = Evaluate(context.Background(), node, ctx); err == nil || err.Error() != "kaput" { |
| 712 | t.Errorf("function error not propagated: %v", err) | 713 | t.Errorf("function error not propagated: %v", err) | ||
| 713 | } | 714 | } | ||
| 714 | // A declared function without an implementation is an evaluation error. | 715 | // A declared function without an implementation is an evaluation error. | ||
| 715 | node, _ = Parse("success()", nil, funcs) | 716 | node, _ = Parse("success()", nil, funcs) | ||
| 716 | - | if _, err = Evaluate(node, EvalContext{}); err == nil { | 717 | + | if _, err = Evaluate(context.Background(), node, EvalContext{}); err == nil { |
| 717 | t.Error("calling an unimplemented function succeeded") | 718 | t.Error("calling an unimplemented function succeeded") | ||
| 718 | } | 719 | } | ||
| 719 | } | 720 | } | ||
| @@ -726,13 +727,13 @@ func TestContextNames(t *testing.T) { | |||||
| 726 | for _, key := range []string{"github", "GITHUB", "GitHub"} { | 727 | for _, key := range []string{"github", "GITHUB", "GitHub"} { | ||
| 727 | d := NewDictionary() | 728 | d := NewDictionary() | ||
| 728 | d.Add("ref", String("main")) | 729 | d.Add("ref", String("main")) | ||
| 729 | - | v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{key: d}}) | 730 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{key: d}}) |
| 730 | if err != nil || CoerceString(v) != "main" { | 731 | if err != nil || CoerceString(v) != "main" { | ||
| 731 | t.Errorf("context key %q: %v, %v", key, v, err) | 732 | t.Errorf("context key %q: %v, %v", key, v, err) | ||
| 732 | } | 733 | } | ||
| 733 | } | 734 | } | ||
| 734 | // A declared but absent context is null. | 735 | // A declared but absent context is null. | ||
| 735 | - | v, err := Evaluate(node, EvalContext{}) | 736 | + | v, err := Evaluate(context.Background(), node, EvalContext{}) |
| 736 | if err != nil || v.Kind() != KindNull { | 737 | if err != nil || v.Kind() != KindNull { | ||
| 737 | t.Errorf("absent context: %v, %v", v, err) | 738 | t.Errorf("absent context: %v, %v", v, err) | ||
| 738 | } | 739 | } | ||
| @@ -742,14 +743,14 @@ func TestContextNames(t *testing.T) { | |||||
| 742 | } | 743 | } | ||
| 743 | // A nil Value in a context behaves as null. | 744 | // A nil Value in a context behaves as null. | ||
| 744 | node, _ = Parse("github.a.b", []string{"github"}, nil) | 745 | node, _ = Parse("github.a.b", []string{"github"}, nil) | ||
| 745 | - | if v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull { | 746 | + | if v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull { |
| 746 | t.Errorf("nil context value: %v, %v", v, err) | 747 | t.Errorf("nil context value: %v, %v", v, err) | ||
| 747 | } | 748 | } | ||
| 748 | var nilDict *Dictionary | 749 | var nilDict *Dictionary | ||
| 749 | var nilArr *Array | 750 | var nilArr *Array | ||
| 750 | node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil) | 751 | node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil) | ||
| 751 | for _, bad := range []Value{nilDict, nilArr} { | 752 | for _, bad := range []Value{nilDict, nilArr} { | ||
| 752 | - | if _, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil { | 753 | + | if _, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil { |
| 753 | t.Errorf("typed nil %T: %v", bad, err) | 754 | t.Errorf("typed nil %T: %v", bad, err) | ||
| 754 | } | 755 | } | ||
| 755 | } | 756 | } | ||
| @@ -766,7 +767,7 @@ func TestEvaluateDoesNotMutateContext(t *testing.T) { | |||||
| 766 | if err != nil { | 767 | if err != nil { | ||
| 767 | t.Fatal(err) | 768 | t.Fatal(err) | ||
| 768 | } | 769 | } | ||
| 769 | - | v, err := Evaluate(node, EvalContext{Contexts: ctxs}) | 770 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: ctxs}) |
| 770 | if err != nil { | 771 | if err != nil { | ||
| 771 | t.Fatal(err) | 772 | t.Fatal(err) | ||
| 772 | } | 773 | } | ||
M expr/funcs.go +69 -18
| @@ -3,15 +3,20 @@ | |||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 6 | 7 | "math" | |
| 7 | 8 | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | // builtinFunc implements a built-in function. It runs inside the evaluator so | |
| 13 | + | // that it can charge the output budget and watch the context. | |
| 14 | + | type builtinFunc func(e *evaluator, args []Value) (Value, error) | |
| 15 | + | ||
| 11 | 16 | // builtinImpls are the implementations of the built-in functions, keyed by | |
| 12 | 17 | // lower-cased name. Arity is validated by the parser, so each may assume its | |
| 13 | 18 | // documented argument count. | |
| 14 | - | var builtinImpls = map[string]Func{ | |
| 19 | + | var builtinImpls = map[string]builtinFunc{ | |
| 15 | 20 | "case": funcCase, | |
| 16 | 21 | "contains": funcContains, | |
| 17 | 22 | "endswith": funcEndsWith, | |
| @@ -27,7 +32,7 @@ var builtinImpls = map[string]Func{ | |||
| 27 | 32 | // The JavaScript reference ships it behind an experimental feature flag | |
| 28 | 33 | // (allowCaseFunction) that only affects editor completion; the parser always | |
| 29 | 34 | // accepts it, and so does this package. | |
| 30 | - | func funcCase(args []Value) (Value, error) { | |
| 35 | + | func funcCase(e *evaluator, args []Value) (Value, error) { | |
| 31 | 36 | for i := 0; i < len(args)-1; i += 2 { | |
| 32 | 37 | pred, ok := normalize(args[i]).(Boolean) | |
| 33 | 38 | if !ok { | |
| @@ -43,15 +48,18 @@ func funcCase(args []Value) (Value, error) { | |||
| 43 | 48 | // funcContains is contains(search, item): substring test, case-insensitive | |
| 44 | 49 | // via toLowerCase, when both are primitive; membership by == in an array; | |
| 45 | 50 | // false otherwise. | |
| 46 | - | func funcContains(args []Value) (Value, error) { | |
| 51 | + | func funcContains(e *evaluator, args []Value) (Value, error) { | |
| 47 | 52 | left, right := normalize(args[0]), normalize(args[1]) | |
| 48 | 53 | switch { | |
| 49 | 54 | case isPrimitive(left): | |
| 50 | 55 | if isPrimitive(right) { | |
| 51 | - | return Boolean(strings.Contains(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil | |
| 56 | + | return Boolean(jsIncludes(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil | |
| 52 | 57 | } | |
| 53 | 58 | case left.Kind() == KindArray: | |
| 54 | 59 | for _, v := range left.(*Array).items { | |
| 60 | + | if err := e.tick(); err != nil { | |
| 61 | + | return nil, err | |
| 62 | + | } | |
| 55 | 63 | if equals(right, v) { | |
| 56 | 64 | return Boolean(true), nil | |
| 57 | 65 | } | |
| @@ -60,14 +68,14 @@ func funcContains(args []Value) (Value, error) { | |||
| 60 | 68 | return Boolean(false), nil | |
| 61 | 69 | } | |
| 62 | 70 | ||
| 63 | - | func funcStartsWith(args []Value) (Value, error) { | |
| 71 | + | func funcStartsWith(e *evaluator, args []Value) (Value, error) { | |
| 64 | 72 | ls, rs, ok := primitivePair(args) | |
| 65 | - | return Boolean(ok && strings.HasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | |
| 73 | + | return Boolean(ok && jsHasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | |
| 66 | 74 | } | |
| 67 | 75 | ||
| 68 | - | func funcEndsWith(args []Value) (Value, error) { | |
| 76 | + | func funcEndsWith(e *evaluator, args []Value) (Value, error) { | |
| 69 | 77 | ls, rs, ok := primitivePair(args) | |
| 70 | - | return Boolean(ok && strings.HasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | |
| 78 | + | return Boolean(ok && jsHasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | |
| 71 | 79 | } | |
| 72 | 80 | ||
| 73 | 81 | // primitivePair returns the string forms of the two arguments, and false | |
| @@ -82,7 +90,7 @@ func primitivePair(args []Value) (string, string, bool) { | |||
| 82 | 90 | // funcJoin is join(array, separator). A primitive first argument is just | |
| 83 | 91 | // converted to a string, ignoring the separator; a dictionary gives "". A | |
| 84 | 92 | // separator that is not primitive means ",". | |
| 85 | - | func funcJoin(args []Value) (Value, error) { | |
| 93 | + | func funcJoin(e *evaluator, args []Value) (Value, error) { | |
| 86 | 94 | first := normalize(args[0]) | |
| 87 | 95 | if isPrimitive(first) { | |
| 88 | 96 | return String(CoerceString(first)), nil | |
| @@ -95,21 +103,51 @@ func funcJoin(args []Value) (Value, error) { | |||
| 95 | 103 | if len(args) > 1 && isPrimitive(args[1]) { | |
| 96 | 104 | sep = CoerceString(args[1]) | |
| 97 | 105 | } | |
| 98 | - | parts := make([]string, len(arr.items)) | |
| 106 | + | var out strings.Builder | |
| 99 | 107 | for i, it := range arr.items { | |
| 100 | - | parts[i] = CoerceString(it) | |
| 108 | + | if err := e.tick(); err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } | |
| 111 | + | part := CoerceString(it) | |
| 112 | + | n := len(part) | |
| 113 | + | if i > 0 { | |
| 114 | + | n += len(sep) | |
| 115 | + | } | |
| 116 | + | if err := e.charge(n); err != nil { | |
| 117 | + | return nil, err | |
| 118 | + | } | |
| 119 | + | if i > 0 { | |
| 120 | + | out.WriteString(sep) | |
| 121 | + | } | |
| 122 | + | out.WriteString(part) | |
| 101 | 123 | } | |
| 102 | - | return String(strings.Join(parts, sep)), nil | |
| 124 | + | return String(normalizeWTF8(out.String())), nil | |
| 103 | 125 | } | |
| 104 | 126 | ||
| 105 | - | func funcToJSON(args []Value) (Value, error) { | |
| 106 | - | return String(ToJSON(args[0])), nil | |
| 127 | + | // funcToJSON is toJSON(value). The output counts against the budget and is | |
| 128 | + | // cut off as soon as it exceeds what is left. | |
| 129 | + | func funcToJSON(e *evaluator, args []Value) (Value, error) { | |
| 130 | + | max := -1 | |
| 131 | + | if e.limit >= 0 { | |
| 132 | + | max = e.limit - e.used | |
| 133 | + | } | |
| 134 | + | out, err := EncodeJSON(args[0], max) | |
| 135 | + | switch { | |
| 136 | + | case errors.Is(err, errJSONTooBig): | |
| 137 | + | return nil, e.budgetError() | |
| 138 | + | case err != nil: | |
| 139 | + | return nil, &EvalError{Message: "toJSON: " + err.Error(), Err: err} | |
| 140 | + | } | |
| 141 | + | if err := e.charge(len(out)); err != nil { | |
| 142 | + | return nil, err | |
| 143 | + | } | |
| 144 | + | return String(out), nil | |
| 107 | 145 | } | |
| 108 | 146 | ||
| 109 | 147 | // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything | |
| 110 | 148 | // that is not valid JSON is "Error parsing JSON when evaluating fromJson" | |
| 111 | 149 | // wrapping the syntax error. | |
| 112 | - | func funcFromJSON(args []Value) (Value, error) { | |
| 150 | + | func funcFromJSON(e *evaluator, args []Value) (Value, error) { | |
| 113 | 151 | in := CoerceString(args[0]) | |
| 114 | 152 | if jsTrim(in) == "" { | |
| 115 | 153 | return nil, evalErrorf("empty input") | |
| @@ -125,7 +163,7 @@ func funcFromJSON(args []Value) (Value, error) { | |||
| 125 | 163 | // are literal braces. Anything between the digits of "{N" and the closing | |
| 126 | 164 | // brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an | |
| 127 | 165 | // error, as is a reference to a missing argument. | |
| 128 | - | func funcFormat(args []Value) (Value, error) { | |
| 166 | + | func funcFormat(e *evaluator, args []Value) (Value, error) { | |
| 129 | 167 | fs := CoerceString(args[0]) | |
| 130 | 168 | var out strings.Builder | |
| 131 | 169 | index := 0 | |
| @@ -136,6 +174,9 @@ func funcFormat(args []Value) (Value, error) { | |||
| 136 | 174 | case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace): | |
| 137 | 175 | // Escaped left brace. | |
| 138 | 176 | if charAt(fs, lbrace+1) == '{' { | |
| 177 | + | if err := e.charge(lbrace + 1 - index); err != nil { | |
| 178 | + | return nil, err | |
| 179 | + | } | |
| 139 | 180 | out.WriteString(fs[index : lbrace+1]) | |
| 140 | 181 | index = lbrace + 2 | |
| 141 | 182 | continue | |
| @@ -146,8 +187,12 @@ func funcFormat(args []Value) (Value, error) { | |||
| 146 | 187 | if 1+argIndex > float64(len(args)-1) { | |
| 147 | 188 | return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs) | |
| 148 | 189 | } | |
| 190 | + | arg := CoerceString(args[1+int(argIndex)]) | |
| 191 | + | if err := e.charge(lbrace - index + len(arg)); err != nil { | |
| 192 | + | return nil, err | |
| 193 | + | } | |
| 149 | 194 | out.WriteString(fs[index:lbrace]) | |
| 150 | - | out.WriteString(CoerceString(args[1+int(argIndex)])) | |
| 195 | + | out.WriteString(arg) | |
| 151 | 196 | index = rbrace + 1 | |
| 152 | 197 | continue | |
| 153 | 198 | } | |
| @@ -158,14 +203,20 @@ func funcFormat(args []Value) (Value, error) { | |||
| 158 | 203 | if charAt(fs, rbrace+1) != '}' { | |
| 159 | 204 | return nil, evalErrorf("The following format string is invalid: %s", fs) | |
| 160 | 205 | } | |
| 206 | + | if err := e.charge(rbrace + 1 - index); err != nil { | |
| 207 | + | return nil, err | |
| 208 | + | } | |
| 161 | 209 | out.WriteString(fs[index : rbrace+1]) | |
| 162 | 210 | index = rbrace + 2 | |
| 163 | 211 | default: | |
| 212 | + | if err := e.charge(len(fs) - index); err != nil { | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 164 | 215 | out.WriteString(fs[index:]) | |
| 165 | 216 | index = len(fs) | |
| 166 | 217 | } | |
| 167 | 218 | } | |
| 168 | - | return String(out.String()), nil | |
| 219 | + | return String(normalizeWTF8(out.String())), nil | |
| 169 | 220 | } | |
| 170 | 221 | ||
| 171 | 222 | func indexByteFrom(s string, c byte, from int) int { | |
| @@ -3,15 +3,20 @@ | |||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "errors" | |||
| 6 | "math" | 7 | "math" | ||
| 7 | "strconv" | 8 | "strconv" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | // builtinFunc implements a built-in function. It runs inside the evaluator so | |||
| 13 | + | // that it can charge the output budget and watch the context. | |||
| 14 | + | type builtinFunc func(e *evaluator, args []Value) (Value, error) | |||
| 15 | + | ||||
| 11 | // builtinImpls are the implementations of the built-in functions, keyed by | 16 | // builtinImpls are the implementations of the built-in functions, keyed by | ||
| 12 | // lower-cased name. Arity is validated by the parser, so each may assume its | 17 | // lower-cased name. Arity is validated by the parser, so each may assume its | ||
| 13 | // documented argument count. | 18 | // documented argument count. | ||
| 14 | - | var builtinImpls = map[string]Func{ | 19 | + | var builtinImpls = map[string]builtinFunc{ |
| 15 | "case": funcCase, | 20 | "case": funcCase, | ||
| 16 | "contains": funcContains, | 21 | "contains": funcContains, | ||
| 17 | "endswith": funcEndsWith, | 22 | "endswith": funcEndsWith, | ||
| @@ -27,7 +32,7 @@ var builtinImpls = map[string]Func{ | |||||
| 27 | // The JavaScript reference ships it behind an experimental feature flag | 32 | // The JavaScript reference ships it behind an experimental feature flag | ||
| 28 | // (allowCaseFunction) that only affects editor completion; the parser always | 33 | // (allowCaseFunction) that only affects editor completion; the parser always | ||
| 29 | // accepts it, and so does this package. | 34 | // accepts it, and so does this package. | ||
| 30 | - | func funcCase(args []Value) (Value, error) { | 35 | + | func funcCase(e *evaluator, args []Value) (Value, error) { |
| 31 | for i := 0; i < len(args)-1; i += 2 { | 36 | for i := 0; i < len(args)-1; i += 2 { | ||
| 32 | pred, ok := normalize(args[i]).(Boolean) | 37 | pred, ok := normalize(args[i]).(Boolean) | ||
| 33 | if !ok { | 38 | if !ok { | ||
| @@ -43,15 +48,18 @@ func funcCase(args []Value) (Value, error) { | |||||
| 43 | // funcContains is contains(search, item): substring test, case-insensitive | 48 | // funcContains is contains(search, item): substring test, case-insensitive | ||
| 44 | // via toLowerCase, when both are primitive; membership by == in an array; | 49 | // via toLowerCase, when both are primitive; membership by == in an array; | ||
| 45 | // false otherwise. | 50 | // false otherwise. | ||
| 46 | - | func funcContains(args []Value) (Value, error) { | 51 | + | func funcContains(e *evaluator, args []Value) (Value, error) { |
| 47 | left, right := normalize(args[0]), normalize(args[1]) | 52 | left, right := normalize(args[0]), normalize(args[1]) | ||
| 48 | switch { | 53 | switch { | ||
| 49 | case isPrimitive(left): | 54 | case isPrimitive(left): | ||
| 50 | if isPrimitive(right) { | 55 | if isPrimitive(right) { | ||
| 51 | - | return Boolean(strings.Contains(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil | 56 | + | return Boolean(jsIncludes(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil |
| 52 | } | 57 | } | ||
| 53 | case left.Kind() == KindArray: | 58 | case left.Kind() == KindArray: | ||
| 54 | for _, v := range left.(*Array).items { | 59 | for _, v := range left.(*Array).items { | ||
| 60 | + | if err := e.tick(); err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 55 | if equals(right, v) { | 63 | if equals(right, v) { | ||
| 56 | return Boolean(true), nil | 64 | return Boolean(true), nil | ||
| 57 | } | 65 | } | ||
| @@ -60,14 +68,14 @@ func funcContains(args []Value) (Value, error) { | |||||
| 60 | return Boolean(false), nil | 68 | return Boolean(false), nil | ||
| 61 | } | 69 | } | ||
| 62 | 70 | ||||
| 63 | - | func funcStartsWith(args []Value) (Value, error) { | 71 | + | func funcStartsWith(e *evaluator, args []Value) (Value, error) { |
| 64 | ls, rs, ok := primitivePair(args) | 72 | ls, rs, ok := primitivePair(args) | ||
| 65 | - | return Boolean(ok && strings.HasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | 73 | + | return Boolean(ok && jsHasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil |
| 66 | } | 74 | } | ||
| 67 | 75 | ||||
| 68 | - | func funcEndsWith(args []Value) (Value, error) { | 76 | + | func funcEndsWith(e *evaluator, args []Value) (Value, error) { |
| 69 | ls, rs, ok := primitivePair(args) | 77 | ls, rs, ok := primitivePair(args) | ||
| 70 | - | return Boolean(ok && strings.HasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil | 78 | + | return Boolean(ok && jsHasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil |
| 71 | } | 79 | } | ||
| 72 | 80 | ||||
| 73 | // primitivePair returns the string forms of the two arguments, and false | 81 | // primitivePair returns the string forms of the two arguments, and false | ||
| @@ -82,7 +90,7 @@ func primitivePair(args []Value) (string, string, bool) { | |||||
| 82 | // funcJoin is join(array, separator). A primitive first argument is just | 90 | // funcJoin is join(array, separator). A primitive first argument is just | ||
| 83 | // converted to a string, ignoring the separator; a dictionary gives "". A | 91 | // converted to a string, ignoring the separator; a dictionary gives "". A | ||
| 84 | // separator that is not primitive means ",". | 92 | // separator that is not primitive means ",". | ||
| 85 | - | func funcJoin(args []Value) (Value, error) { | 93 | + | func funcJoin(e *evaluator, args []Value) (Value, error) { |
| 86 | first := normalize(args[0]) | 94 | first := normalize(args[0]) | ||
| 87 | if isPrimitive(first) { | 95 | if isPrimitive(first) { | ||
| 88 | return String(CoerceString(first)), nil | 96 | return String(CoerceString(first)), nil | ||
| @@ -95,21 +103,51 @@ func funcJoin(args []Value) (Value, error) { | |||||
| 95 | if len(args) > 1 && isPrimitive(args[1]) { | 103 | if len(args) > 1 && isPrimitive(args[1]) { | ||
| 96 | sep = CoerceString(args[1]) | 104 | sep = CoerceString(args[1]) | ||
| 97 | } | 105 | } | ||
| 98 | - | parts := make([]string, len(arr.items)) | 106 | + | var out strings.Builder |
| 99 | for i, it := range arr.items { | 107 | for i, it := range arr.items { | ||
| 100 | - | parts[i] = CoerceString(it) | 108 | + | if err := e.tick(); err != nil { |
| 109 | + | return nil, err | |||
| 110 | + | } | |||
| 111 | + | part := CoerceString(it) | |||
| 112 | + | n := len(part) | |||
| 113 | + | if i > 0 { | |||
| 114 | + | n += len(sep) | |||
| 115 | + | } | |||
| 116 | + | if err := e.charge(n); err != nil { | |||
| 117 | + | return nil, err | |||
| 118 | + | } | |||
| 119 | + | if i > 0 { | |||
| 120 | + | out.WriteString(sep) | |||
| 121 | + | } | |||
| 122 | + | out.WriteString(part) | |||
| 101 | } | 123 | } | ||
| 102 | - | return String(strings.Join(parts, sep)), nil | 124 | + | return String(normalizeWTF8(out.String())), nil |
| 103 | } | 125 | } | ||
| 104 | 126 | ||||
| 105 | - | func funcToJSON(args []Value) (Value, error) { | 127 | + | // funcToJSON is toJSON(value). The output counts against the budget and is |
| 106 | - | return String(ToJSON(args[0])), nil | 128 | + | // cut off as soon as it exceeds what is left. |
| 129 | + | func funcToJSON(e *evaluator, args []Value) (Value, error) { | |||
| 130 | + | max := -1 | |||
| 131 | + | if e.limit >= 0 { | |||
| 132 | + | max = e.limit - e.used | |||
| 133 | + | } | |||
| 134 | + | out, err := EncodeJSON(args[0], max) | |||
| 135 | + | switch { | |||
| 136 | + | case errors.Is(err, errJSONTooBig): | |||
| 137 | + | return nil, e.budgetError() | |||
| 138 | + | case err != nil: | |||
| 139 | + | return nil, &EvalError{Message: "toJSON: " + err.Error(), Err: err} | |||
| 140 | + | } | |||
| 141 | + | if err := e.charge(len(out)); err != nil { | |||
| 142 | + | return nil, err | |||
| 143 | + | } | |||
| 144 | + | return String(out), nil | |||
| 107 | } | 145 | } | ||
| 108 | 146 | ||||
| 109 | // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything | 147 | // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything | ||
| 110 | // that is not valid JSON is "Error parsing JSON when evaluating fromJson" | 148 | // that is not valid JSON is "Error parsing JSON when evaluating fromJson" | ||
| 111 | // wrapping the syntax error. | 149 | // wrapping the syntax error. | ||
| 112 | - | func funcFromJSON(args []Value) (Value, error) { | 150 | + | func funcFromJSON(e *evaluator, args []Value) (Value, error) { |
| 113 | in := CoerceString(args[0]) | 151 | in := CoerceString(args[0]) | ||
| 114 | if jsTrim(in) == "" { | 152 | if jsTrim(in) == "" { | ||
| 115 | return nil, evalErrorf("empty input") | 153 | return nil, evalErrorf("empty input") | ||
| @@ -125,7 +163,7 @@ func funcFromJSON(args []Value) (Value, error) { | |||||
| 125 | // are literal braces. Anything between the digits of "{N" and the closing | 163 | // are literal braces. Anything between the digits of "{N" and the closing | ||
| 126 | // brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an | 164 | // brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an | ||
| 127 | // error, as is a reference to a missing argument. | 165 | // error, as is a reference to a missing argument. | ||
| 128 | - | func funcFormat(args []Value) (Value, error) { | 166 | + | func funcFormat(e *evaluator, args []Value) (Value, error) { |
| 129 | fs := CoerceString(args[0]) | 167 | fs := CoerceString(args[0]) | ||
| 130 | var out strings.Builder | 168 | var out strings.Builder | ||
| 131 | index := 0 | 169 | index := 0 | ||
| @@ -136,6 +174,9 @@ func funcFormat(args []Value) (Value, error) { | |||||
| 136 | case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace): | 174 | case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace): | ||
| 137 | // Escaped left brace. | 175 | // Escaped left brace. | ||
| 138 | if charAt(fs, lbrace+1) == '{' { | 176 | if charAt(fs, lbrace+1) == '{' { | ||
| 177 | + | if err := e.charge(lbrace + 1 - index); err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 139 | out.WriteString(fs[index : lbrace+1]) | 180 | out.WriteString(fs[index : lbrace+1]) | ||
| 140 | index = lbrace + 2 | 181 | index = lbrace + 2 | ||
| 141 | continue | 182 | continue | ||
| @@ -146,8 +187,12 @@ func funcFormat(args []Value) (Value, error) { | |||||
| 146 | if 1+argIndex > float64(len(args)-1) { | 187 | if 1+argIndex > float64(len(args)-1) { | ||
| 147 | return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs) | 188 | return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs) | ||
| 148 | } | 189 | } | ||
| 190 | + | arg := CoerceString(args[1+int(argIndex)]) | |||
| 191 | + | if err := e.charge(lbrace - index + len(arg)); err != nil { | |||
| 192 | + | return nil, err | |||
| 193 | + | } | |||
| 149 | out.WriteString(fs[index:lbrace]) | 194 | out.WriteString(fs[index:lbrace]) | ||
| 150 | - | out.WriteString(CoerceString(args[1+int(argIndex)])) | 195 | + | out.WriteString(arg) |
| 151 | index = rbrace + 1 | 196 | index = rbrace + 1 | ||
| 152 | continue | 197 | continue | ||
| 153 | } | 198 | } | ||
| @@ -158,14 +203,20 @@ func funcFormat(args []Value) (Value, error) { | |||||
| 158 | if charAt(fs, rbrace+1) != '}' { | 203 | if charAt(fs, rbrace+1) != '}' { | ||
| 159 | return nil, evalErrorf("The following format string is invalid: %s", fs) | 204 | return nil, evalErrorf("The following format string is invalid: %s", fs) | ||
| 160 | } | 205 | } | ||
| 206 | + | if err := e.charge(rbrace + 1 - index); err != nil { | |||
| 207 | + | return nil, err | |||
| 208 | + | } | |||
| 161 | out.WriteString(fs[index : rbrace+1]) | 209 | out.WriteString(fs[index : rbrace+1]) | ||
| 162 | index = rbrace + 2 | 210 | index = rbrace + 2 | ||
| 163 | default: | 211 | default: | ||
| 212 | + | if err := e.charge(len(fs) - index); err != nil { | |||
| 213 | + | return nil, err | |||
| 214 | + | } | |||
| 164 | out.WriteString(fs[index:]) | 215 | out.WriteString(fs[index:]) | ||
| 165 | index = len(fs) | 216 | index = len(fs) | ||
| 166 | } | 217 | } | ||
| 167 | } | 218 | } | ||
| 168 | - | return String(out.String()), nil | 219 | + | return String(normalizeWTF8(out.String())), nil |
| 169 | } | 220 | } | ||
| 170 | 221 | ||||
| 171 | func indexByteFrom(s string, c byte, from int) int { | 222 | func indexByteFrom(s string, c byte, from int) int { | ||
M expr/fuzz_test.go +5 -4
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "unicode/utf8" | |
| @@ -16,7 +17,7 @@ var fuzzSeeds = []string{ | |||
| 16 | 17 | "fromJSON('[1,{\"a\":null}]')[1].a", "toJSON(fromJSON('{\"b\":1,\"1\":2}'))", "join(matrix.node.*, ', ')", "case(true, 1, 2)", "case(false, 1, true, 2, 3)", | |
| 17 | 18 | "((((((((((1))))))))))", "!!!!!!true", "1 || 2 && 3 == 4 < 5", "hashFiles('**/*.go', '!vendor/**')", "success() && always()", "\n\t \r", "\u00a0", "'😀' == '\U00010000'", | |
| 18 | 19 | "=", "&", "|", "!=", "<=", ">=", "()", "(1", "1)", "a.", ".a", "a..b", "a[", "a[]", "a[*]", "*", "a.*.*", "1.2.3", "--1", "1_000", "'\xed\xa0\x80'", | |
| 19 | - | "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'", | |
| 20 | + | "fromJSON('\"\\ud800\"') == fromJSON('\"\\udc00\"')", "format('{0}{1}', fromJSON('\"\\ud83d\"'), fromJSON('\"\\ude00\"'))", "contains(fromJSON('\"\\ud83d\\ude00\"'), fromJSON('\"\\ude00\"'))", "format('{0}{0}', format('{0}{0}', 'xxxxxxxx'))", "github.event.commits.*.*.*", "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'", | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | func addSeeds(f *testing.F) { | |
| @@ -127,8 +128,8 @@ func FuzzEvaluate(f *testing.F) { | |||
| 127 | 128 | "env": mustJSON(f, `{"A":"","B":"1","C":"x","J":"{\"a\":[1,2]}"}`), | |
| 128 | 129 | }, | |
| 129 | 130 | Funcs: map[string]Func{ | |
| 130 | - | "hashfiles": func(args []Value) (Value, error) { return String("h"), nil }, | |
| 131 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | |
| 131 | + | "hashfiles": func(_ context.Context, args []Value) (Value, error) { return String("h"), nil }, | |
| 132 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | |
| 132 | 133 | }, | |
| 133 | 134 | } | |
| 134 | 135 | before := map[string]string{} | |
| @@ -140,7 +141,7 @@ func FuzzEvaluate(f *testing.F) { | |||
| 140 | 141 | if err != nil || node == nil { | |
| 141 | 142 | return | |
| 142 | 143 | } | |
| 143 | - | v, err := Evaluate(node, ctx) | |
| 144 | + | v, err := Evaluate(context.Background(), node, ctx) | |
| 144 | 145 | if err != nil { | |
| 145 | 146 | if _, ok := err.(*EvalError); !ok { | |
| 146 | 147 | t.Fatalf("error is %T, not *EvalError", err) | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "unicode/utf8" | 9 | "unicode/utf8" | ||
| @@ -16,7 +17,7 @@ var fuzzSeeds = []string{ | |||||
| 16 | "fromJSON('[1,{\"a\":null}]')[1].a", "toJSON(fromJSON('{\"b\":1,\"1\":2}'))", "join(matrix.node.*, ', ')", "case(true, 1, 2)", "case(false, 1, true, 2, 3)", | 17 | "fromJSON('[1,{\"a\":null}]')[1].a", "toJSON(fromJSON('{\"b\":1,\"1\":2}'))", "join(matrix.node.*, ', ')", "case(true, 1, 2)", "case(false, 1, true, 2, 3)", | ||
| 17 | "((((((((((1))))))))))", "!!!!!!true", "1 || 2 && 3 == 4 < 5", "hashFiles('**/*.go', '!vendor/**')", "success() && always()", "\n\t \r", "\u00a0", "'😀' == '\U00010000'", | 18 | "((((((((((1))))))))))", "!!!!!!true", "1 || 2 && 3 == 4 < 5", "hashFiles('**/*.go', '!vendor/**')", "success() && always()", "\n\t \r", "\u00a0", "'😀' == '\U00010000'", | ||
| 18 | "=", "&", "|", "!=", "<=", ">=", "()", "(1", "1)", "a.", ".a", "a..b", "a[", "a[]", "a[*]", "*", "a.*.*", "1.2.3", "--1", "1_000", "'\xed\xa0\x80'", | 19 | "=", "&", "|", "!=", "<=", ">=", "()", "(1", "1)", "a.", ".a", "a..b", "a[", "a[]", "a[*]", "*", "a.*.*", "1.2.3", "--1", "1_000", "'\xed\xa0\x80'", | ||
| 19 | - | "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'", | 20 | + | "fromJSON('\"\\ud800\"') == fromJSON('\"\\udc00\"')", "format('{0}{1}', fromJSON('\"\\ud83d\"'), fromJSON('\"\\ude00\"'))", "contains(fromJSON('\"\\ud83d\\ude00\"'), fromJSON('\"\\ude00\"'))", "format('{0}{0}', format('{0}{0}', 'xxxxxxxx'))", "github.event.commits.*.*.*", "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'", |
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | func addSeeds(f *testing.F) { | 23 | func addSeeds(f *testing.F) { | ||
| @@ -127,8 +128,8 @@ func FuzzEvaluate(f *testing.F) { | |||||
| 127 | "env": mustJSON(f, `{"A":"","B":"1","C":"x","J":"{\"a\":[1,2]}"}`), | 128 | "env": mustJSON(f, `{"A":"","B":"1","C":"x","J":"{\"a\":[1,2]}"}`), | ||
| 128 | }, | 129 | }, | ||
| 129 | Funcs: map[string]Func{ | 130 | Funcs: map[string]Func{ | ||
| 130 | - | "hashfiles": func(args []Value) (Value, error) { return String("h"), nil }, | 131 | + | "hashfiles": func(_ context.Context, args []Value) (Value, error) { return String("h"), nil }, |
| 131 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | 132 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, |
| 132 | }, | 133 | }, | ||
| 133 | } | 134 | } | ||
| 134 | before := map[string]string{} | 135 | before := map[string]string{} | ||
| @@ -140,7 +141,7 @@ func FuzzEvaluate(f *testing.F) { | |||||
| 140 | if err != nil || node == nil { | 141 | if err != nil || node == nil { | ||
| 141 | return | 142 | return | ||
| 142 | } | 143 | } | ||
| 143 | - | v, err := Evaluate(node, ctx) | 144 | + | v, err := Evaluate(context.Background(), node, ctx) |
| 144 | if err != nil { | 145 | if err != nil { | ||
| 145 | if _, ok := err.(*EvalError); !ok { | 146 | if _, ok := err.(*EvalError); !ok { | ||
| 146 | t.Fatalf("error is %T, not *EvalError", err) | 147 | t.Fatalf("error is %T, not *EvalError", err) | ||
M expr/gen/oracle.mjs +52 -3
| @@ -5,11 +5,19 @@ | |||
| 5 | 5 | // tests (oracle_test.go) replay the corpus and compare token by token what | |
| 6 | 6 | // GitHub's JavaScript library does with what this package does. | |
| 7 | 7 | // | |
| 8 | - | // node gen/oracle.mjs /path/to/@actions/expressions/dist | gzip -9 -n > testdata/oracle.json.gz | |
| 8 | + | // node gen/oracle.mjs /path/to/@actions/expressions/dist [/path/to/actions-expressions-X.tgz] \\ | |
| 9 | + | // | gzip -9 -n > testdata/oracle.json.gz | |
| 10 | + | // | |
| 11 | + | // The optional tarball is the npm package the dist directory came from: its | |
| 12 | + | // sha512 (base64, the "integrity" field of the npm registry) is recorded next | |
| 13 | + | // to the Node version in the output, so the provenance of the oracle is | |
| 14 | + | // checkable. | |
| 9 | 15 | // | |
| 10 | 16 | // The corpus is hand-written edge cases plus a seeded pseudo-random walk over | |
| 11 | 17 | // the grammar and over mutations of it, so the file is reproducible. | |
| 12 | 18 | ||
| 19 | + | import { createHash } from "node:crypto"; | |
| 20 | + | import { readFileSync } from "node:fs"; | |
| 13 | 21 | import { pathToFileURL } from "node:url"; | |
| 14 | 22 | import path from "node:path"; | |
| 15 | 23 | ||
| @@ -17,6 +25,18 @@ const dist = path.resolve(process.argv[2] ?? "node_modules/@actions/expressions/ | |||
| 17 | 25 | const ex = await import(pathToFileURL(path.join(dist, "index.js")).href); | |
| 18 | 26 | const { Lexer, Parser, Evaluator, data } = ex; | |
| 19 | 27 | ||
| 28 | + | const pkgJson = JSON.parse(readFileSync(path.join(dist, "..", "package.json"), "utf8")); | |
| 29 | + | const integrity = process.argv[3] | |
| 30 | + | ? "sha512-" + createHash("sha512").update(readFileSync(process.argv[3])).digest("base64") | |
| 31 | + | : ""; | |
| 32 | + | const reference = { | |
| 33 | + | package: `${pkgJson.name} ${pkgJson.version}`, | |
| 34 | + | integrity, | |
| 35 | + | node: process.version, | |
| 36 | + | v8: process.versions.v8, | |
| 37 | + | unicode: process.versions.unicode, | |
| 38 | + | }; | |
| 39 | + | ||
| 20 | 40 | // ---- contexts ------------------------------------------------------------- | |
| 21 | 41 | ||
| 22 | 42 | const contexts = { | |
| @@ -128,7 +148,14 @@ function run(expr) { | |||
| 128 | 148 | return out; | |
| 129 | 149 | } | |
| 130 | 150 | if (res === undefined) { out.skip = "undefined result"; return out; } | |
| 131 | - | out.r = { k: kindOf(res), s: res.coerceString(), j: JSON.stringify(res, data.replacer, " ") }; | |
| 151 | + | const str = res.coerceString(); | |
| 152 | + | out.r = { k: kindOf(res), s: str, j: JSON.stringify(res, data.replacer, " ") }; | |
| 153 | + | if (!str.isWellFormed()) { | |
| 154 | + | // A string holding a lone surrogate cannot travel through JSON text and | |
| 155 | + | // UTF-8: give its UTF-16 code units instead. | |
| 156 | + | out.r.s = ""; | |
| 157 | + | out.r.u = Array.from({ length: str.length }, (_, i) => str.charCodeAt(i)); | |
| 158 | + | } | |
| 132 | 159 | return out; | |
| 133 | 160 | } | |
| 134 | 161 | ||
| @@ -233,6 +260,28 @@ add("contains('a', 'b',)", "contains(1,)", "contains(,1)", "contains('a' 'b')", | |||
| 233 | 260 | "join(fromJSON(toJSON(github.event.labels.*.name)), '+')", "toJSON(fromJSON(toJSON(github.event.commits)))", "format('{0}', toJSON(null))", "fromJSON(format('[{0},{1}]', 1, 2))[1]", "fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a", "toJSON(fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a[0])", "toJSON(fromJSON('[[1,2],[3]]').*[0])", "toJSON(fromJSON('[[1,2],[3]]')[*][1])", | |
| 234 | 261 | "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}')[*].x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":2}').*.x)", "toJSON(fromJSON('[1,2]').*.x)", "toJSON(fromJSON('1').*)", "toJSON(null.*)", "toJSON('abc'.*)", "toJSON(fromJSON('[]').*.a)", "toJSON(fromJSON('[]').*)", "toJSON(fromJSON('{}').*)"); | |
| 235 | 262 | ||
| 263 | + | // lone surrogates (kept as WTF-8 by the Go port) | |
| 264 | + | { | |
| 265 | + | const H = "fromJSON('\"\\ud800\"')", L = "fromJSON('\"\\udc00\"')", H2 = "fromJSON('\"\\ud83d\"')", L2 = "fromJSON('\"\\ude00\"')"; | |
| 266 | + | const lone = [H, L, H2, L2, "'\u{1F600}'", "'\ue000'", "'\uffff'", "'a'", "''", "'\ufffd'", "'\u{10000}'"]; | |
| 267 | + | for (const a of lone) { | |
| 268 | + | add(`toJSON(${a})`, `format('{0}', ${a})`, `join(${a})`, `${a} || 'd'`, `startsWith(${a}, ${a})`, `case(true, ${a}, 1)`); | |
| 269 | + | for (const b of lone) { | |
| 270 | + | for (const op of ["==", "!=", "<", "<=", ">", ">="]) add(`${a} ${op} ${b}`); | |
| 271 | + | add(`contains(${a}, ${b})`, `startsWith(${a}, ${b})`, `endsWith(${a}, ${b})`, `format('{0}{1}', ${a}, ${b})`, `join(fromJSON('[]'), ${a}) == ''`, | |
| 272 | + | `toJSON(fromJSON(toJSON(${a})))`, `format('{0}', ${a}) == format('{0}', ${b})`); | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | add(`${H} == ${L}`, `${H} != ${L}`, `${H} < ${L}`, `${H} > ${L}`, `contains('x${"\u{1F600}"}', ${H2})`, `contains('x\u{1F600}', ${L2})`, `startsWith('\u{1F600}', ${H2})`, `endsWith('\u{1F600}', ${L2})`, | |
| 276 | + | `format('{0}{1}', ${H2}, ${L2}) == '\u{1F600}'`, `format('{0}{1}', ${L2}, ${H2}) == '\u{1F600}'`, `join(fromJSON('["\\ud83d","\\ude00"]'), '') == '\u{1F600}'`, | |
| 277 | + | `join(fromJSON('["\\ud83d","\\ude00"]'), '')`, `format('{0}{1}', ${H2}, ${L2})`, `format('{0}{1}{0}', ${H2}, ${L2})`, `toJSON(format('{0}{1}', ${H2}, ${L2}))`, | |
| 278 | + | `toJSON(fromJSON('{"\\ud800":1,"\\ud800x":2,"a\\udc00":3}'))`, `fromJSON('{"\\ud800":1}')[${H}]`, `fromJSON('{"\\ud800":1}')[${L}]`, `toJSON(fromJSON('["\\ud800\\ud800","\\udc00\\ud800"]'))`, | |
| 279 | + | `${H} == '\ufffd'`, `'\ufffd' == ${H}`, `'\ufffd' < ${H}`, `${H} < '\ufffd'`, `${H} < '\u{1F600}'`, `'\u{1F600}' < ${H}`, `${L} < '\ue000'`, `'\ue000' < ${L}`, | |
| 280 | + | `fromJSON('"\\ud800\\u0041"') == 'A'`, `contains(fromJSON('"A\\ud800"'), 'a')`, `startsWith(fromJSON('"a\\ud800"'), 'A')`, `endsWith(fromJSON('"\\ud800a"'), 'A')`, | |
| 281 | + | `contains(fromJSON('"\\ud800\\udc00"'), ${H})`, `contains(fromJSON('"\\ud800\\udc00"'), ${L})`, `startsWith(fromJSON('"\\ud800\\udc00"'), ${H})`, `endsWith(fromJSON('"\\ud800\\udc00"'), ${L})`, | |
| 282 | + | `contains(fromJSON('["\\ud800"]'), ${H})`, `contains(fromJSON('["\\ud800"]'), ${L})`, `fromJSON('"\\ud800"') == fromJSON('"\\ud800"')`); | |
| 283 | + | } | |
| 284 | + | ||
| 236 | 285 | // depth and length limits | |
| 237 | 286 | const nest = (n, open, body, close) => open.repeat(n) + body + close.repeat(n); | |
| 238 | 287 | for (const n of [1, 10, 24, 25, 26, 48, 49, 50, 51, 52, 100, 200]) { | |
| @@ -314,4 +363,4 @@ for (const e of corpus) { | |||
| 314 | 363 | } | |
| 315 | 364 | const skipped = cases.filter(c => c.skip).length; | |
| 316 | 365 | console.error(`cases=${cases.length} skipped(JS crash)=${skipped}`); | |
| 317 | - | process.stdout.write(JSON.stringify({ reference: "@actions/expressions 0.3.61", contexts, cases: cases.filter(c => !c.skip) }) + "\n"); | |
| 366 | + | process.stdout.write(JSON.stringify({ reference, contexts, cases: cases.filter(c => !c.skip) }) + "\n"); | |
| @@ -5,11 +5,19 @@ | |||||
| 5 | // tests (oracle_test.go) replay the corpus and compare token by token what | 5 | // tests (oracle_test.go) replay the corpus and compare token by token what | ||
| 6 | // GitHub's JavaScript library does with what this package does. | 6 | // GitHub's JavaScript library does with what this package does. | ||
| 7 | // | 7 | // | ||
| 8 | - | // node gen/oracle.mjs /path/to/@actions/expressions/dist | gzip -9 -n > testdata/oracle.json.gz | 8 | + | // node gen/oracle.mjs /path/to/@actions/expressions/dist [/path/to/actions-expressions-X.tgz] \\ |
| 9 | + | // | gzip -9 -n > testdata/oracle.json.gz | |||
| 10 | + | // | |||
| 11 | + | // The optional tarball is the npm package the dist directory came from: its | |||
| 12 | + | // sha512 (base64, the "integrity" field of the npm registry) is recorded next | |||
| 13 | + | // to the Node version in the output, so the provenance of the oracle is | |||
| 14 | + | // checkable. | |||
| 9 | // | 15 | // | ||
| 10 | // The corpus is hand-written edge cases plus a seeded pseudo-random walk over | 16 | // The corpus is hand-written edge cases plus a seeded pseudo-random walk over | ||
| 11 | // the grammar and over mutations of it, so the file is reproducible. | 17 | // the grammar and over mutations of it, so the file is reproducible. | ||
| 12 | 18 | ||||
| 19 | + | import { createHash } from "node:crypto"; | |||
| 20 | + | import { readFileSync } from "node:fs"; | |||
| 13 | import { pathToFileURL } from "node:url"; | 21 | import { pathToFileURL } from "node:url"; | ||
| 14 | import path from "node:path"; | 22 | import path from "node:path"; | ||
| 15 | 23 | ||||
| @@ -17,6 +25,18 @@ const dist = path.resolve(process.argv[2] ?? "node_modules/@actions/expressions/ | |||||
| 17 | const ex = await import(pathToFileURL(path.join(dist, "index.js")).href); | 25 | const ex = await import(pathToFileURL(path.join(dist, "index.js")).href); | ||
| 18 | const { Lexer, Parser, Evaluator, data } = ex; | 26 | const { Lexer, Parser, Evaluator, data } = ex; | ||
| 19 | 27 | ||||
| 28 | + | const pkgJson = JSON.parse(readFileSync(path.join(dist, "..", "package.json"), "utf8")); | |||
| 29 | + | const integrity = process.argv[3] | |||
| 30 | + | ? "sha512-" + createHash("sha512").update(readFileSync(process.argv[3])).digest("base64") | |||
| 31 | + | : ""; | |||
| 32 | + | const reference = { | |||
| 33 | + | package: `${pkgJson.name} ${pkgJson.version}`, | |||
| 34 | + | integrity, | |||
| 35 | + | node: process.version, | |||
| 36 | + | v8: process.versions.v8, | |||
| 37 | + | unicode: process.versions.unicode, | |||
| 38 | + | }; | |||
| 39 | + | ||||
| 20 | // ---- contexts ------------------------------------------------------------- | 40 | // ---- contexts ------------------------------------------------------------- | ||
| 21 | 41 | ||||
| 22 | const contexts = { | 42 | const contexts = { | ||
| @@ -128,7 +148,14 @@ function run(expr) { | |||||
| 128 | return out; | 148 | return out; | ||
| 129 | } | 149 | } | ||
| 130 | if (res === undefined) { out.skip = "undefined result"; return out; } | 150 | if (res === undefined) { out.skip = "undefined result"; return out; } | ||
| 131 | - | out.r = { k: kindOf(res), s: res.coerceString(), j: JSON.stringify(res, data.replacer, " ") }; | 151 | + | const str = res.coerceString(); |
| 152 | + | out.r = { k: kindOf(res), s: str, j: JSON.stringify(res, data.replacer, " ") }; | |||
| 153 | + | if (!str.isWellFormed()) { | |||
| 154 | + | // A string holding a lone surrogate cannot travel through JSON text and | |||
| 155 | + | // UTF-8: give its UTF-16 code units instead. | |||
| 156 | + | out.r.s = ""; | |||
| 157 | + | out.r.u = Array.from({ length: str.length }, (_, i) => str.charCodeAt(i)); | |||
| 158 | + | } | |||
| 132 | return out; | 159 | return out; | ||
| 133 | } | 160 | } | ||
| 134 | 161 | ||||
| @@ -233,6 +260,28 @@ add("contains('a', 'b',)", "contains(1,)", "contains(,1)", "contains('a' 'b')", | |||||
| 233 | "join(fromJSON(toJSON(github.event.labels.*.name)), '+')", "toJSON(fromJSON(toJSON(github.event.commits)))", "format('{0}', toJSON(null))", "fromJSON(format('[{0},{1}]', 1, 2))[1]", "fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a", "toJSON(fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a[0])", "toJSON(fromJSON('[[1,2],[3]]').*[0])", "toJSON(fromJSON('[[1,2],[3]]')[*][1])", | 260 | "join(fromJSON(toJSON(github.event.labels.*.name)), '+')", "toJSON(fromJSON(toJSON(github.event.commits)))", "format('{0}', toJSON(null))", "fromJSON(format('[{0},{1}]', 1, 2))[1]", "fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a", "toJSON(fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a[0])", "toJSON(fromJSON('[[1,2],[3]]').*[0])", "toJSON(fromJSON('[[1,2],[3]]')[*][1])", | ||
| 234 | "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}')[*].x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":2}').*.x)", "toJSON(fromJSON('[1,2]').*.x)", "toJSON(fromJSON('1').*)", "toJSON(null.*)", "toJSON('abc'.*)", "toJSON(fromJSON('[]').*.a)", "toJSON(fromJSON('[]').*)", "toJSON(fromJSON('{}').*)"); | 261 | "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}')[*].x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":2}').*.x)", "toJSON(fromJSON('[1,2]').*.x)", "toJSON(fromJSON('1').*)", "toJSON(null.*)", "toJSON('abc'.*)", "toJSON(fromJSON('[]').*.a)", "toJSON(fromJSON('[]').*)", "toJSON(fromJSON('{}').*)"); | ||
| 235 | 262 | ||||
| 263 | + | // lone surrogates (kept as WTF-8 by the Go port) | |||
| 264 | + | { | |||
| 265 | + | const H = "fromJSON('\"\\ud800\"')", L = "fromJSON('\"\\udc00\"')", H2 = "fromJSON('\"\\ud83d\"')", L2 = "fromJSON('\"\\ude00\"')"; | |||
| 266 | + | const lone = [H, L, H2, L2, "'\u{1F600}'", "'\ue000'", "'\uffff'", "'a'", "''", "'\ufffd'", "'\u{10000}'"]; | |||
| 267 | + | for (const a of lone) { | |||
| 268 | + | add(`toJSON(${a})`, `format('{0}', ${a})`, `join(${a})`, `${a} || 'd'`, `startsWith(${a}, ${a})`, `case(true, ${a}, 1)`); | |||
| 269 | + | for (const b of lone) { | |||
| 270 | + | for (const op of ["==", "!=", "<", "<=", ">", ">="]) add(`${a} ${op} ${b}`); | |||
| 271 | + | add(`contains(${a}, ${b})`, `startsWith(${a}, ${b})`, `endsWith(${a}, ${b})`, `format('{0}{1}', ${a}, ${b})`, `join(fromJSON('[]'), ${a}) == ''`, | |||
| 272 | + | `toJSON(fromJSON(toJSON(${a})))`, `format('{0}', ${a}) == format('{0}', ${b})`); | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | add(`${H} == ${L}`, `${H} != ${L}`, `${H} < ${L}`, `${H} > ${L}`, `contains('x${"\u{1F600}"}', ${H2})`, `contains('x\u{1F600}', ${L2})`, `startsWith('\u{1F600}', ${H2})`, `endsWith('\u{1F600}', ${L2})`, | |||
| 276 | + | `format('{0}{1}', ${H2}, ${L2}) == '\u{1F600}'`, `format('{0}{1}', ${L2}, ${H2}) == '\u{1F600}'`, `join(fromJSON('["\\ud83d","\\ude00"]'), '') == '\u{1F600}'`, | |||
| 277 | + | `join(fromJSON('["\\ud83d","\\ude00"]'), '')`, `format('{0}{1}', ${H2}, ${L2})`, `format('{0}{1}{0}', ${H2}, ${L2})`, `toJSON(format('{0}{1}', ${H2}, ${L2}))`, | |||
| 278 | + | `toJSON(fromJSON('{"\\ud800":1,"\\ud800x":2,"a\\udc00":3}'))`, `fromJSON('{"\\ud800":1}')[${H}]`, `fromJSON('{"\\ud800":1}')[${L}]`, `toJSON(fromJSON('["\\ud800\\ud800","\\udc00\\ud800"]'))`, | |||
| 279 | + | `${H} == '\ufffd'`, `'\ufffd' == ${H}`, `'\ufffd' < ${H}`, `${H} < '\ufffd'`, `${H} < '\u{1F600}'`, `'\u{1F600}' < ${H}`, `${L} < '\ue000'`, `'\ue000' < ${L}`, | |||
| 280 | + | `fromJSON('"\\ud800\\u0041"') == 'A'`, `contains(fromJSON('"A\\ud800"'), 'a')`, `startsWith(fromJSON('"a\\ud800"'), 'A')`, `endsWith(fromJSON('"\\ud800a"'), 'A')`, | |||
| 281 | + | `contains(fromJSON('"\\ud800\\udc00"'), ${H})`, `contains(fromJSON('"\\ud800\\udc00"'), ${L})`, `startsWith(fromJSON('"\\ud800\\udc00"'), ${H})`, `endsWith(fromJSON('"\\ud800\\udc00"'), ${L})`, | |||
| 282 | + | `contains(fromJSON('["\\ud800"]'), ${H})`, `contains(fromJSON('["\\ud800"]'), ${L})`, `fromJSON('"\\ud800"') == fromJSON('"\\ud800"')`); | |||
| 283 | + | } | |||
| 284 | + | ||||
| 236 | // depth and length limits | 285 | // depth and length limits | ||
| 237 | const nest = (n, open, body, close) => open.repeat(n) + body + close.repeat(n); | 286 | const nest = (n, open, body, close) => open.repeat(n) + body + close.repeat(n); | ||
| 238 | for (const n of [1, 10, 24, 25, 26, 48, 49, 50, 51, 52, 100, 200]) { | 287 | for (const n of [1, 10, 24, 25, 26, 48, 49, 50, 51, 52, 100, 200]) { | ||
| @@ -314,4 +363,4 @@ for (const e of corpus) { | |||||
| 314 | } | 363 | } | ||
| 315 | const skipped = cases.filter(c => c.skip).length; | 364 | const skipped = cases.filter(c => c.skip).length; | ||
| 316 | console.error(`cases=${cases.length} skipped(JS crash)=${skipped}`); | 365 | console.error(`cases=${cases.length} skipped(JS crash)=${skipped}`); | ||
| 317 | - | process.stdout.write(JSON.stringify({ reference: "@actions/expressions 0.3.61", contexts, cases: cases.filter(c => !c.skip) }) + "\n"); | 366 | + | process.stdout.write(JSON.stringify({ reference, contexts, cases: cases.filter(c => !c.skip) }) + "\n"); |
M expr/json.go +142 -77
| @@ -12,10 +12,12 @@ import ( | |||
| 12 | 12 | "unicode/utf8" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | // maxJSONDepth bounds the nesting FromJSON accepts. JavaScript engines fail | |
| 16 | - | // with a stack overflow somewhere in that region, which fromJSON reports like | |
| 17 | - | // any other parse error. | |
| 18 | - | const maxJSONDepth = 10000 | |
| 15 | + | // MaxJSONDepth bounds the nesting that FromJSON accepts and that ToJSON and | |
| 16 | + | // EncodeJSON write. JavaScript engines fail with a stack overflow somewhere | |
| 17 | + | // in that region; fromJSON reports it like any other parse error. The bound | |
| 18 | + | // also stops the encoder on cyclic values, which can be built through | |
| 19 | + | // [Array.Append]. | |
| 20 | + | const MaxJSONDepth = 1000 | |
| 19 | 21 | ||
| 20 | 22 | // FromJSON parses JSON text into a [Value], like the fromJSON function. | |
| 21 | 23 | // | |
| @@ -80,7 +82,7 @@ func (p *jsonParser) literal(word string, v Value) (Value, error) { | |||
| 80 | 82 | } | |
| 81 | 83 | ||
| 82 | 84 | func (p *jsonParser) value(depth int) (Value, error) { | |
| 83 | - | if depth > maxJSONDepth { | |
| 85 | + | if depth > MaxJSONDepth { | |
| 84 | 86 | return nil, errors.New("maximum JSON nesting depth exceeded") | |
| 85 | 87 | } | |
| 86 | 88 | if p.i >= len(p.s) { | |
| @@ -204,16 +206,10 @@ func (p *jsonParser) str() (string, error) { | |||
| 204 | 206 | p.i = save // not a pair; the second escape is read on its own | |
| 205 | 207 | } | |
| 206 | 208 | } | |
| 207 | - | if r >= 0xD800 && r < 0xE000 { | |
| 208 | - | // A lone surrogate cannot be a Go rune. Keep it as its | |
| 209 | - | // three-byte WTF-8 form so that toJSON writes it back as | |
| 210 | - | // the same \uXXXX escape, like JSON.stringify. | |
| 211 | - | b.WriteByte(0xE0 | byte(r>>12)) | |
| 212 | - | b.WriteByte(0x80 | byte(r>>6)&0x3F) | |
| 213 | - | b.WriteByte(0x80 | byte(r)&0x3F) | |
| 214 | - | continue | |
| 215 | - | } | |
| 216 | - | b.WriteRune(r) | |
| 209 | + | // A lone surrogate cannot be a Go rune: writeRune keeps it in | |
| 210 | + | // its three-byte WTF-8 form so that toJSON writes it back as | |
| 211 | + | // the same \uXXXX escape, like JSON.stringify. | |
| 212 | + | writeRune(&b, r) | |
| 217 | 213 | default: | |
| 218 | 214 | p.i-- | |
| 219 | 215 | return "", p.unexpected() | |
| @@ -376,116 +372,185 @@ func jsKeyOrder(keys []string) []string { | |||
| 376 | 372 | // JavaScript object. Control characters, quotes and backslashes in strings | |
| 377 | 373 | // are escaped; everything else, including non-ASCII text, is written as is. | |
| 378 | 374 | // Lone surrogates, which FromJSON keeps in WTF-8 form, are written as \uXXXX. | |
| 375 | + | // | |
| 376 | + | // ToJSON suits the finite values that FromJSON and Evaluate produce. A value | |
| 377 | + | // nested deeper than [MaxJSONDepth], which only a cyclic value built by hand | |
| 378 | + | // can be, makes it return "null"; use [EncodeJSON] to get an error instead | |
| 379 | + | // and to bound the output. | |
| 379 | 380 | func ToJSON(v Value) string { | |
| 380 | - | var b strings.Builder | |
| 381 | - | writeJSON(&b, v, 0) | |
| 382 | - | return b.String() | |
| 381 | + | s, err := EncodeJSON(v, -1) | |
| 382 | + | if err != nil { | |
| 383 | + | return "null" | |
| 384 | + | } | |
| 385 | + | return s | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // EncodeJSON is [ToJSON] with limits: it fails when v is nested deeper than | |
| 389 | + | // [MaxJSONDepth] and, when maxBytes is not negative, as soon as the output | |
| 390 | + | // would exceed maxBytes bytes, without building the rest. | |
| 391 | + | func EncodeJSON(v Value, maxBytes int) (string, error) { | |
| 392 | + | w := &jsonWriter{limit: maxBytes} | |
| 393 | + | w.value(v, 0) | |
| 394 | + | if w.err != nil { | |
| 395 | + | return "", w.err | |
| 396 | + | } | |
| 397 | + | return w.b.String(), nil | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | var ( | |
| 401 | + | errJSONTooDeep = errors.New("value nested too deeply to encode as JSON (a cycle?)") | |
| 402 | + | errJSONTooBig = errors.New("JSON output exceeds the size limit") | |
| 403 | + | ) | |
| 404 | + | ||
| 405 | + | type jsonWriter struct { | |
| 406 | + | b strings.Builder | |
| 407 | + | limit int // negative: unlimited | |
| 408 | + | err error | |
| 383 | 409 | } | |
| 384 | 410 | ||
| 385 | - | func writeJSON(b *strings.Builder, v Value, depth int) { | |
| 411 | + | // grow reports whether n more bytes may be written, recording the error | |
| 412 | + | // otherwise. | |
| 413 | + | func (w *jsonWriter) grow(n int) bool { | |
| 414 | + | if w.err != nil { | |
| 415 | + | return false | |
| 416 | + | } | |
| 417 | + | if w.limit >= 0 && n > w.limit-w.b.Len() { | |
| 418 | + | w.err = errJSONTooBig | |
| 419 | + | return false | |
| 420 | + | } | |
| 421 | + | return true | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func (w *jsonWriter) str(s string) { | |
| 425 | + | if w.grow(len(s)) { | |
| 426 | + | w.b.WriteString(s) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (w *jsonWriter) indent(depth int) { | |
| 431 | + | if w.grow(2 * depth) { | |
| 432 | + | for i := 0; i < depth; i++ { | |
| 433 | + | w.b.WriteString(" ") | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func (w *jsonWriter) value(v Value, depth int) { | |
| 439 | + | if w.err != nil { | |
| 440 | + | return | |
| 441 | + | } | |
| 442 | + | if depth > MaxJSONDepth { | |
| 443 | + | w.err = errJSONTooDeep | |
| 444 | + | return | |
| 445 | + | } | |
| 386 | 446 | switch x := normalize(v).(type) { | |
| 387 | 447 | case Null: | |
| 388 | - | b.WriteString("null") | |
| 448 | + | w.str("null") | |
| 389 | 449 | case Boolean: | |
| 390 | 450 | if x { | |
| 391 | - | b.WriteString("true") | |
| 451 | + | w.str("true") | |
| 392 | 452 | } else { | |
| 393 | - | b.WriteString("false") | |
| 453 | + | w.str("false") | |
| 394 | 454 | } | |
| 395 | 455 | case Number: | |
| 396 | 456 | f := float64(x) | |
| 397 | 457 | if math.IsNaN(f) || math.IsInf(f, 0) { | |
| 398 | - | b.WriteString("null") | |
| 458 | + | w.str("null") | |
| 399 | 459 | } else { | |
| 400 | - | b.WriteString(jsNumberToString(f)) | |
| 460 | + | w.str(jsNumberToString(f)) | |
| 401 | 461 | } | |
| 402 | 462 | case String: | |
| 403 | - | writeJSONString(b, string(x)) | |
| 463 | + | w.jsonString(string(x)) | |
| 404 | 464 | case *Array: | |
| 405 | 465 | if len(x.items) == 0 { | |
| 406 | - | b.WriteString("[]") | |
| 466 | + | w.str("[]") | |
| 407 | 467 | return | |
| 408 | 468 | } | |
| 409 | - | b.WriteString("[\n") | |
| 469 | + | w.str("[\n") | |
| 410 | 470 | for i, it := range x.items { | |
| 411 | - | indent(b, depth+1) | |
| 412 | - | writeJSON(b, it, depth+1) | |
| 471 | + | w.indent(depth + 1) | |
| 472 | + | w.value(it, depth+1) | |
| 413 | 473 | if i < len(x.items)-1 { | |
| 414 | - | b.WriteByte(',') | |
| 474 | + | w.str(",") | |
| 475 | + | } | |
| 476 | + | w.str("\n") | |
| 477 | + | if w.err != nil { | |
| 478 | + | return | |
| 415 | 479 | } | |
| 416 | - | b.WriteByte('\n') | |
| 417 | 480 | } | |
| 418 | - | indent(b, depth) | |
| 419 | - | b.WriteByte(']') | |
| 481 | + | w.indent(depth) | |
| 482 | + | w.str("]") | |
| 420 | 483 | case *Dictionary: | |
| 421 | 484 | if len(x.keys) == 0 { | |
| 422 | - | b.WriteString("{}") | |
| 485 | + | w.str("{}") | |
| 423 | 486 | return | |
| 424 | 487 | } | |
| 425 | 488 | keys := jsKeyOrder(x.keys) | |
| 426 | - | b.WriteString("{\n") | |
| 489 | + | w.str("{\n") | |
| 427 | 490 | for i, k := range keys { | |
| 428 | 491 | val, _ := x.Get(k) | |
| 429 | - | indent(b, depth+1) | |
| 430 | - | writeJSONString(b, k) | |
| 431 | - | b.WriteString(": ") | |
| 432 | - | writeJSON(b, val, depth+1) | |
| 492 | + | w.indent(depth + 1) | |
| 493 | + | w.jsonString(k) | |
| 494 | + | w.str(": ") | |
| 495 | + | w.value(val, depth+1) | |
| 433 | 496 | if i < len(keys)-1 { | |
| 434 | - | b.WriteByte(',') | |
| 497 | + | w.str(",") | |
| 498 | + | } | |
| 499 | + | w.str("\n") | |
| 500 | + | if w.err != nil { | |
| 501 | + | return | |
| 435 | 502 | } | |
| 436 | - | b.WriteByte('\n') | |
| 437 | 503 | } | |
| 438 | - | indent(b, depth) | |
| 439 | - | b.WriteByte('}') | |
| 440 | - | } | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | func indent(b *strings.Builder, depth int) { | |
| 444 | - | for i := 0; i < depth; i++ { | |
| 445 | - | b.WriteString(" ") | |
| 504 | + | w.indent(depth) | |
| 505 | + | w.str("}") | |
| 446 | 506 | } | |
| 447 | 507 | } | |
| 448 | 508 | ||
| 449 | 509 | const hexDigits = "0123456789abcdef" | |
| 450 | 510 | ||
| 451 | - | func writeJSONString(b *strings.Builder, s string) { | |
| 452 | - | b.WriteByte('"') | |
| 511 | + | func (w *jsonWriter) jsonString(s string) { | |
| 512 | + | if !w.grow(len(s) + 2) { | |
| 513 | + | return | |
| 514 | + | } | |
| 515 | + | w.b.WriteByte('"') | |
| 453 | 516 | for i := 0; i < len(s); { | |
| 454 | - | r, w := utf8.DecodeRuneInString(s[i:]) | |
| 455 | - | if r == utf8.RuneError && w == 1 && i+2 < len(s) && s[i] == 0xED && s[i+1] >= 0xA0 && s[i+1] <= 0xBF && s[i+2]&0xC0 == 0x80 { | |
| 456 | - | // WTF-8 lone surrogate (see FromJSON). | |
| 457 | - | sur := rune(s[i]&0x0F)<<12 | rune(s[i+1]&0x3F)<<6 | rune(s[i+2]&0x3F) | |
| 458 | - | b.WriteString(`\u`) | |
| 459 | - | b.WriteByte(hexDigits[sur>>12]) | |
| 460 | - | b.WriteByte(hexDigits[sur>>8&0xF]) | |
| 461 | - | b.WriteByte(hexDigits[sur>>4&0xF]) | |
| 462 | - | b.WriteByte(hexDigits[sur&0xF]) | |
| 463 | - | i += 3 | |
| 464 | - | continue | |
| 465 | - | } | |
| 466 | - | i += w | |
| 517 | + | r, n := decodeRune(s[i:]) | |
| 518 | + | i += n | |
| 519 | + | var esc string | |
| 467 | 520 | switch { | |
| 468 | 521 | case r == '"': | |
| 469 | - | b.WriteString(`\"`) | |
| 522 | + | esc = `\"` | |
| 470 | 523 | case r == '\\': | |
| 471 | - | b.WriteString(`\\`) | |
| 524 | + | esc = `\\` | |
| 472 | 525 | case r == '\b': | |
| 473 | - | b.WriteString(`\b`) | |
| 526 | + | esc = `\b` | |
| 474 | 527 | case r == '\f': | |
| 475 | - | b.WriteString(`\f`) | |
| 528 | + | esc = `\f` | |
| 476 | 529 | case r == '\n': | |
| 477 | - | b.WriteString(`\n`) | |
| 530 | + | esc = `\n` | |
| 478 | 531 | case r == '\r': | |
| 479 | - | b.WriteString(`\r`) | |
| 532 | + | esc = `\r` | |
| 480 | 533 | case r == '\t': | |
| 481 | - | b.WriteString(`\t`) | |
| 534 | + | esc = `\t` | |
| 482 | 535 | case r < 0x20: | |
| 483 | - | b.WriteString(`\u00`) | |
| 484 | - | b.WriteByte(hexDigits[r>>4]) | |
| 485 | - | b.WriteByte(hexDigits[r&0xF]) | |
| 486 | - | default: | |
| 487 | - | b.WriteRune(r) // other invalid UTF-8 comes out as U+FFFD | |
| 536 | + | esc = `\u00` + string([]byte{hexDigits[r>>4], hexDigits[r&0xF]}) | |
| 537 | + | case r >= 0xD800 && r < 0xE000: | |
| 538 | + | // A lone surrogate (WTF-8, see FromJSON). | |
| 539 | + | esc = `\u` + string([]byte{hexDigits[r>>12], hexDigits[r>>8&0xF], hexDigits[r>>4&0xF], hexDigits[r&0xF]}) | |
| 540 | + | } | |
| 541 | + | if esc != "" { | |
| 542 | + | if !w.grow(len(esc)) { | |
| 543 | + | return | |
| 544 | + | } | |
| 545 | + | w.b.WriteString(esc) | |
| 546 | + | continue | |
| 488 | 547 | } | |
| 548 | + | if !w.grow(utf8.RuneLen(r)) { | |
| 549 | + | return | |
| 550 | + | } | |
| 551 | + | w.b.WriteRune(r) // other invalid UTF-8 comes out as U+FFFD | |
| 552 | + | } | |
| 553 | + | if w.grow(1) { | |
| 554 | + | w.b.WriteByte('"') | |
| 489 | 555 | } | |
| 490 | - | b.WriteByte('"') | |
| 491 | 556 | } | |
| @@ -12,10 +12,12 @@ import ( | |||||
| 12 | "unicode/utf8" | 12 | "unicode/utf8" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | // maxJSONDepth bounds the nesting FromJSON accepts. JavaScript engines fail | 15 | + | // MaxJSONDepth bounds the nesting that FromJSON accepts and that ToJSON and |
| 16 | - | // with a stack overflow somewhere in that region, which fromJSON reports like | 16 | + | // EncodeJSON write. JavaScript engines fail with a stack overflow somewhere |
| 17 | - | // any other parse error. | 17 | + | // in that region; fromJSON reports it like any other parse error. The bound |
| 18 | - | const maxJSONDepth = 10000 | 18 | + | // also stops the encoder on cyclic values, which can be built through |
| 19 | + | // [Array.Append]. | |||
| 20 | + | const MaxJSONDepth = 1000 | |||
| 19 | 21 | ||||
| 20 | // FromJSON parses JSON text into a [Value], like the fromJSON function. | 22 | // FromJSON parses JSON text into a [Value], like the fromJSON function. | ||
| 21 | // | 23 | // | ||
| @@ -80,7 +82,7 @@ func (p *jsonParser) literal(word string, v Value) (Value, error) { | |||||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 82 | func (p *jsonParser) value(depth int) (Value, error) { | 84 | func (p *jsonParser) value(depth int) (Value, error) { | ||
| 83 | - | if depth > maxJSONDepth { | 85 | + | if depth > MaxJSONDepth { |
| 84 | return nil, errors.New("maximum JSON nesting depth exceeded") | 86 | return nil, errors.New("maximum JSON nesting depth exceeded") | ||
| 85 | } | 87 | } | ||
| 86 | if p.i >= len(p.s) { | 88 | if p.i >= len(p.s) { | ||
| @@ -204,16 +206,10 @@ func (p *jsonParser) str() (string, error) { | |||||
| 204 | p.i = save // not a pair; the second escape is read on its own | 206 | p.i = save // not a pair; the second escape is read on its own | ||
| 205 | } | 207 | } | ||
| 206 | } | 208 | } | ||
| 207 | - | if r >= 0xD800 && r < 0xE000 { | 209 | + | // A lone surrogate cannot be a Go rune: writeRune keeps it in |
| 208 | - | // A lone surrogate cannot be a Go rune. Keep it as its | 210 | + | // its three-byte WTF-8 form so that toJSON writes it back as |
| 209 | - | // three-byte WTF-8 form so that toJSON writes it back as | 211 | + | // the same \uXXXX escape, like JSON.stringify. |
| 210 | - | // the same \uXXXX escape, like JSON.stringify. | 212 | + | writeRune(&b, r) |
| 211 | - | b.WriteByte(0xE0 | byte(r>>12)) | |||
| 212 | - | b.WriteByte(0x80 | byte(r>>6)&0x3F) | |||
| 213 | - | b.WriteByte(0x80 | byte(r)&0x3F) | |||
| 214 | - | continue | |||
| 215 | - | } | |||
| 216 | - | b.WriteRune(r) | |||
| 217 | default: | 213 | default: | ||
| 218 | p.i-- | 214 | p.i-- | ||
| 219 | return "", p.unexpected() | 215 | return "", p.unexpected() | ||
| @@ -376,116 +372,185 @@ func jsKeyOrder(keys []string) []string { | |||||
| 376 | // JavaScript object. Control characters, quotes and backslashes in strings | 372 | // JavaScript object. Control characters, quotes and backslashes in strings | ||
| 377 | // are escaped; everything else, including non-ASCII text, is written as is. | 373 | // are escaped; everything else, including non-ASCII text, is written as is. | ||
| 378 | // Lone surrogates, which FromJSON keeps in WTF-8 form, are written as \uXXXX. | 374 | // Lone surrogates, which FromJSON keeps in WTF-8 form, are written as \uXXXX. | ||
| 375 | + | // | |||
| 376 | + | // ToJSON suits the finite values that FromJSON and Evaluate produce. A value | |||
| 377 | + | // nested deeper than [MaxJSONDepth], which only a cyclic value built by hand | |||
| 378 | + | // can be, makes it return "null"; use [EncodeJSON] to get an error instead | |||
| 379 | + | // and to bound the output. | |||
| 379 | func ToJSON(v Value) string { | 380 | func ToJSON(v Value) string { | ||
| 380 | - | var b strings.Builder | 381 | + | s, err := EncodeJSON(v, -1) |
| 381 | - | writeJSON(&b, v, 0) | 382 | + | if err != nil { |
| 382 | - | return b.String() | 383 | + | return "null" |
| 384 | + | } | |||
| 385 | + | return s | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // EncodeJSON is [ToJSON] with limits: it fails when v is nested deeper than | |||
| 389 | + | // [MaxJSONDepth] and, when maxBytes is not negative, as soon as the output | |||
| 390 | + | // would exceed maxBytes bytes, without building the rest. | |||
| 391 | + | func EncodeJSON(v Value, maxBytes int) (string, error) { | |||
| 392 | + | w := &jsonWriter{limit: maxBytes} | |||
| 393 | + | w.value(v, 0) | |||
| 394 | + | if w.err != nil { | |||
| 395 | + | return "", w.err | |||
| 396 | + | } | |||
| 397 | + | return w.b.String(), nil | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | var ( | |||
| 401 | + | errJSONTooDeep = errors.New("value nested too deeply to encode as JSON (a cycle?)") | |||
| 402 | + | errJSONTooBig = errors.New("JSON output exceeds the size limit") | |||
| 403 | + | ) | |||
| 404 | + | ||||
| 405 | + | type jsonWriter struct { | |||
| 406 | + | b strings.Builder | |||
| 407 | + | limit int // negative: unlimited | |||
| 408 | + | err error | |||
| 383 | } | 409 | } | ||
| 384 | 410 | ||||
| 385 | - | func writeJSON(b *strings.Builder, v Value, depth int) { | 411 | + | // grow reports whether n more bytes may be written, recording the error |
| 412 | + | // otherwise. | |||
| 413 | + | func (w *jsonWriter) grow(n int) bool { | |||
| 414 | + | if w.err != nil { | |||
| 415 | + | return false | |||
| 416 | + | } | |||
| 417 | + | if w.limit >= 0 && n > w.limit-w.b.Len() { | |||
| 418 | + | w.err = errJSONTooBig | |||
| 419 | + | return false | |||
| 420 | + | } | |||
| 421 | + | return true | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (w *jsonWriter) str(s string) { | |||
| 425 | + | if w.grow(len(s)) { | |||
| 426 | + | w.b.WriteString(s) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func (w *jsonWriter) indent(depth int) { | |||
| 431 | + | if w.grow(2 * depth) { | |||
| 432 | + | for i := 0; i < depth; i++ { | |||
| 433 | + | w.b.WriteString(" ") | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func (w *jsonWriter) value(v Value, depth int) { | |||
| 439 | + | if w.err != nil { | |||
| 440 | + | return | |||
| 441 | + | } | |||
| 442 | + | if depth > MaxJSONDepth { | |||
| 443 | + | w.err = errJSONTooDeep | |||
| 444 | + | return | |||
| 445 | + | } | |||
| 386 | switch x := normalize(v).(type) { | 446 | switch x := normalize(v).(type) { | ||
| 387 | case Null: | 447 | case Null: | ||
| 388 | - | b.WriteString("null") | 448 | + | w.str("null") |
| 389 | case Boolean: | 449 | case Boolean: | ||
| 390 | if x { | 450 | if x { | ||
| 391 | - | b.WriteString("true") | 451 | + | w.str("true") |
| 392 | } else { | 452 | } else { | ||
| 393 | - | b.WriteString("false") | 453 | + | w.str("false") |
| 394 | } | 454 | } | ||
| 395 | case Number: | 455 | case Number: | ||
| 396 | f := float64(x) | 456 | f := float64(x) | ||
| 397 | if math.IsNaN(f) || math.IsInf(f, 0) { | 457 | if math.IsNaN(f) || math.IsInf(f, 0) { | ||
| 398 | - | b.WriteString("null") | 458 | + | w.str("null") |
| 399 | } else { | 459 | } else { | ||
| 400 | - | b.WriteString(jsNumberToString(f)) | 460 | + | w.str(jsNumberToString(f)) |
| 401 | } | 461 | } | ||
| 402 | case String: | 462 | case String: | ||
| 403 | - | writeJSONString(b, string(x)) | 463 | + | w.jsonString(string(x)) |
| 404 | case *Array: | 464 | case *Array: | ||
| 405 | if len(x.items) == 0 { | 465 | if len(x.items) == 0 { | ||
| 406 | - | b.WriteString("[]") | 466 | + | w.str("[]") |
| 407 | return | 467 | return | ||
| 408 | } | 468 | } | ||
| 409 | - | b.WriteString("[\n") | 469 | + | w.str("[\n") |
| 410 | for i, it := range x.items { | 470 | for i, it := range x.items { | ||
| 411 | - | indent(b, depth+1) | 471 | + | w.indent(depth + 1) |
| 412 | - | writeJSON(b, it, depth+1) | 472 | + | w.value(it, depth+1) |
| 413 | if i < len(x.items)-1 { | 473 | if i < len(x.items)-1 { | ||
| 414 | - | b.WriteByte(',') | 474 | + | w.str(",") |
| 475 | + | } | |||
| 476 | + | w.str("\n") | |||
| 477 | + | if w.err != nil { | |||
| 478 | + | return | |||
| 415 | } | 479 | } | ||
| 416 | - | b.WriteByte('\n') | |||
| 417 | } | 480 | } | ||
| 418 | - | indent(b, depth) | 481 | + | w.indent(depth) |
| 419 | - | b.WriteByte(']') | 482 | + | w.str("]") |
| 420 | case *Dictionary: | 483 | case *Dictionary: | ||
| 421 | if len(x.keys) == 0 { | 484 | if len(x.keys) == 0 { | ||
| 422 | - | b.WriteString("{}") | 485 | + | w.str("{}") |
| 423 | return | 486 | return | ||
| 424 | } | 487 | } | ||
| 425 | keys := jsKeyOrder(x.keys) | 488 | keys := jsKeyOrder(x.keys) | ||
| 426 | - | b.WriteString("{\n") | 489 | + | w.str("{\n") |
| 427 | for i, k := range keys { | 490 | for i, k := range keys { | ||
| 428 | val, _ := x.Get(k) | 491 | val, _ := x.Get(k) | ||
| 429 | - | indent(b, depth+1) | 492 | + | w.indent(depth + 1) |
| 430 | - | writeJSONString(b, k) | 493 | + | w.jsonString(k) |
| 431 | - | b.WriteString(": ") | 494 | + | w.str(": ") |
| 432 | - | writeJSON(b, val, depth+1) | 495 | + | w.value(val, depth+1) |
| 433 | if i < len(keys)-1 { | 496 | if i < len(keys)-1 { | ||
| 434 | - | b.WriteByte(',') | 497 | + | w.str(",") |
| 498 | + | } | |||
| 499 | + | w.str("\n") | |||
| 500 | + | if w.err != nil { | |||
| 501 | + | return | |||
| 435 | } | 502 | } | ||
| 436 | - | b.WriteByte('\n') | |||
| 437 | } | 503 | } | ||
| 438 | - | indent(b, depth) | 504 | + | w.indent(depth) |
| 439 | - | b.WriteByte('}') | 505 | + | w.str("}") |
| 440 | - | } | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | func indent(b *strings.Builder, depth int) { | |||
| 444 | - | for i := 0; i < depth; i++ { | |||
| 445 | - | b.WriteString(" ") | |||
| 446 | } | 506 | } | ||
| 447 | } | 507 | } | ||
| 448 | 508 | ||||
| 449 | const hexDigits = "0123456789abcdef" | 509 | const hexDigits = "0123456789abcdef" | ||
| 450 | 510 | ||||
| 451 | - | func writeJSONString(b *strings.Builder, s string) { | 511 | + | func (w *jsonWriter) jsonString(s string) { |
| 452 | - | b.WriteByte('"') | 512 | + | if !w.grow(len(s) + 2) { |
| 513 | + | return | |||
| 514 | + | } | |||
| 515 | + | w.b.WriteByte('"') | |||
| 453 | for i := 0; i < len(s); { | 516 | for i := 0; i < len(s); { | ||
| 454 | - | r, w := utf8.DecodeRuneInString(s[i:]) | 517 | + | r, n := decodeRune(s[i:]) |
| 455 | - | if r == utf8.RuneError && w == 1 && i+2 < len(s) && s[i] == 0xED && s[i+1] >= 0xA0 && s[i+1] <= 0xBF && s[i+2]&0xC0 == 0x80 { | 518 | + | i += n |
| 456 | - | // WTF-8 lone surrogate (see FromJSON). | 519 | + | var esc string |
| 457 | - | sur := rune(s[i]&0x0F)<<12 | rune(s[i+1]&0x3F)<<6 | rune(s[i+2]&0x3F) | |||
| 458 | - | b.WriteString(`\u`) | |||
| 459 | - | b.WriteByte(hexDigits[sur>>12]) | |||
| 460 | - | b.WriteByte(hexDigits[sur>>8&0xF]) | |||
| 461 | - | b.WriteByte(hexDigits[sur>>4&0xF]) | |||
| 462 | - | b.WriteByte(hexDigits[sur&0xF]) | |||
| 463 | - | i += 3 | |||
| 464 | - | continue | |||
| 465 | - | } | |||
| 466 | - | i += w | |||
| 467 | switch { | 520 | switch { | ||
| 468 | case r == '"': | 521 | case r == '"': | ||
| 469 | - | b.WriteString(`\"`) | 522 | + | esc = `\"` |
| 470 | case r == '\\': | 523 | case r == '\\': | ||
| 471 | - | b.WriteString(`\\`) | 524 | + | esc = `\\` |
| 472 | case r == '\b': | 525 | case r == '\b': | ||
| 473 | - | b.WriteString(`\b`) | 526 | + | esc = `\b` |
| 474 | case r == '\f': | 527 | case r == '\f': | ||
| 475 | - | b.WriteString(`\f`) | 528 | + | esc = `\f` |
| 476 | case r == '\n': | 529 | case r == '\n': | ||
| 477 | - | b.WriteString(`\n`) | 530 | + | esc = `\n` |
| 478 | case r == '\r': | 531 | case r == '\r': | ||
| 479 | - | b.WriteString(`\r`) | 532 | + | esc = `\r` |
| 480 | case r == '\t': | 533 | case r == '\t': | ||
| 481 | - | b.WriteString(`\t`) | 534 | + | esc = `\t` |
| 482 | case r < 0x20: | 535 | case r < 0x20: | ||
| 483 | - | b.WriteString(`\u00`) | 536 | + | esc = `\u00` + string([]byte{hexDigits[r>>4], hexDigits[r&0xF]}) |
| 484 | - | b.WriteByte(hexDigits[r>>4]) | 537 | + | case r >= 0xD800 && r < 0xE000: |
| 485 | - | b.WriteByte(hexDigits[r&0xF]) | 538 | + | // A lone surrogate (WTF-8, see FromJSON). |
| 486 | - | default: | 539 | + | esc = `\u` + string([]byte{hexDigits[r>>12], hexDigits[r>>8&0xF], hexDigits[r>>4&0xF], hexDigits[r&0xF]}) |
| 487 | - | b.WriteRune(r) // other invalid UTF-8 comes out as U+FFFD | 540 | + | } |
| 541 | + | if esc != "" { | |||
| 542 | + | if !w.grow(len(esc)) { | |||
| 543 | + | return | |||
| 544 | + | } | |||
| 545 | + | w.b.WriteString(esc) | |||
| 546 | + | continue | |||
| 488 | } | 547 | } | ||
| 548 | + | if !w.grow(utf8.RuneLen(r)) { | |||
| 549 | + | return | |||
| 550 | + | } | |||
| 551 | + | w.b.WriteRune(r) // other invalid UTF-8 comes out as U+FFFD | |||
| 552 | + | } | |||
| 553 | + | if w.grow(1) { | |||
| 554 | + | w.b.WriteByte('"') | |||
| 489 | } | 555 | } | ||
| 490 | - | b.WriteByte('"') | |||
| 491 | } | 556 | } | ||
M expr/json_test.go +3 -3
| @@ -93,11 +93,11 @@ func TestFromJSONInvalidUTF8(t *testing.T) { | |||
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | func TestFromJSONDeepNesting(t *testing.T) { | |
| 96 | - | deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000) | |
| 96 | + | deep := strings.Repeat("[", 900) + strings.Repeat("]", 900) | |
| 97 | 97 | if _, err := FromJSON([]byte(deep)); err != nil { | |
| 98 | - | t.Errorf("depth 5000: %v", err) | |
| 98 | + | t.Errorf("depth 900: %v", err) | |
| 99 | 99 | } | |
| 100 | - | tooDeep := strings.Repeat("[", maxJSONDepth+10) + strings.Repeat("]", maxJSONDepth+10) | |
| 100 | + | tooDeep := strings.Repeat("[", MaxJSONDepth+10) + strings.Repeat("]", MaxJSONDepth+10) | |
| 101 | 101 | if _, err := FromJSON([]byte(tooDeep)); err == nil { | |
| 102 | 102 | t.Error("absurd depth accepted") | |
| 103 | 103 | } | |
| @@ -93,11 +93,11 @@ func TestFromJSONInvalidUTF8(t *testing.T) { | |||||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | func TestFromJSONDeepNesting(t *testing.T) { | 95 | func TestFromJSONDeepNesting(t *testing.T) { | ||
| 96 | - | deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000) | 96 | + | deep := strings.Repeat("[", 900) + strings.Repeat("]", 900) |
| 97 | if _, err := FromJSON([]byte(deep)); err != nil { | 97 | if _, err := FromJSON([]byte(deep)); err != nil { | ||
| 98 | - | t.Errorf("depth 5000: %v", err) | 98 | + | t.Errorf("depth 900: %v", err) |
| 99 | } | 99 | } | ||
| 100 | - | tooDeep := strings.Repeat("[", maxJSONDepth+10) + strings.Repeat("]", maxJSONDepth+10) | 100 | + | tooDeep := strings.Repeat("[", MaxJSONDepth+10) + strings.Repeat("]", MaxJSONDepth+10) |
| 101 | if _, err := FromJSON([]byte(tooDeep)); err == nil { | 101 | if _, err := FromJSON([]byte(tooDeep)); err == nil { | ||
| 102 | t.Error("absurd depth accepted") | 102 | t.Error("absurd depth accepted") | ||
| 103 | } | 103 | } | ||
M expr/lexer.go +5 -3
| @@ -84,12 +84,14 @@ func tokenString(t Token) string { | |||
| 84 | 84 | // Lex splits an expression into tokens. The result always ends with a | |
| 85 | 85 | // TokenEOF token. | |
| 86 | 86 | func Lex(input string) ([]Token, error) { | |
| 87 | - | units := utf16.Encode([]rune(input)) | |
| 88 | - | if len(units) > MaxExpressionLength { | |
| 87 | + | if exceedsUTF16Len(input, MaxExpressionLength) { | |
| 89 | 88 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | |
| 90 | - | // not the descriptive ExpressionError the parser would build. | |
| 89 | + | // not the descriptive ExpressionError the parser would build. The | |
| 90 | + | // length is checked before the input is converted so that a huge | |
| 91 | + | // input costs no allocation. | |
| 91 | 92 | return nil, &Error{Type: ErrorExceededMaxLength, Message: "ErrorExceededMaxLength"} | |
| 92 | 93 | } | |
| 94 | + | units := utf16.Encode([]rune(input)) | |
| 93 | 95 | lx := &lexer{input: string(utf16.Decode(units)), units: units} | |
| 94 | 96 | if err := lx.lex(); err != nil { | |
| 95 | 97 | return nil, err | |
| @@ -84,12 +84,14 @@ func tokenString(t Token) string { | |||||
| 84 | // Lex splits an expression into tokens. The result always ends with a | 84 | // Lex splits an expression into tokens. The result always ends with a | ||
| 85 | // TokenEOF token. | 85 | // TokenEOF token. | ||
| 86 | func Lex(input string) ([]Token, error) { | 86 | func Lex(input string) ([]Token, error) { | ||
| 87 | - | units := utf16.Encode([]rune(input)) | 87 | + | if exceedsUTF16Len(input, MaxExpressionLength) { |
| 88 | - | if len(units) > MaxExpressionLength { | |||
| 89 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | 88 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | ||
| 90 | - | // not the descriptive ExpressionError the parser would build. | 89 | + | // not the descriptive ExpressionError the parser would build. The |
| 90 | + | // length is checked before the input is converted so that a huge | |||
| 91 | + | // input costs no allocation. | |||
| 91 | return nil, &Error{Type: ErrorExceededMaxLength, Message: "ErrorExceededMaxLength"} | 92 | return nil, &Error{Type: ErrorExceededMaxLength, Message: "ErrorExceededMaxLength"} | ||
| 92 | } | 93 | } | ||
| 94 | + | units := utf16.Encode([]rune(input)) | |||
| 93 | lx := &lexer{input: string(utf16.Decode(units)), units: units} | 95 | lx := &lexer{input: string(utf16.Decode(units)), units: units} | ||
| 94 | if err := lx.lex(); err != nil { | 96 | if err := lx.lex(); err != nil { | ||
| 95 | return nil, err | 97 | return nil, err | ||
M expr/oracle_test.go +172 -104
| @@ -4,9 +4,13 @@ package expr | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "compress/gzip" | |
| 7 | + | "context" | |
| 7 | 8 | "encoding/json" | |
| 9 | + | "errors" | |
| 8 | 10 | "fmt" | |
| 11 | + | "math" | |
| 9 | 12 | "os" | |
| 13 | + | "strings" | |
| 10 | 14 | "testing" | |
| 11 | 15 | ) | |
| 12 | 16 | ||
| @@ -18,11 +22,20 @@ import ( | |||
| 18 | 22 | // string coercions and toJSON output. | |
| 19 | 23 | ||
| 20 | 24 | type oracleFile struct { | |
| 21 | - | Reference string `json:"reference"` | |
| 25 | + | Reference oracleReference `json:"reference"` | |
| 22 | 26 | Contexts map[string]json.RawMessage `json:"contexts"` | |
| 23 | 27 | Cases []oracleCase `json:"cases"` | |
| 24 | 28 | } | |
| 25 | 29 | ||
| 30 | + | // oracleReference records where the oracle comes from. | |
| 31 | + | type oracleReference struct { | |
| 32 | + | Package string `json:"package"` | |
| 33 | + | Integrity string `json:"integrity"` | |
| 34 | + | Node string `json:"node"` | |
| 35 | + | V8 string `json:"v8"` | |
| 36 | + | Unicode string `json:"unicode"` | |
| 37 | + | } | |
| 38 | + | ||
| 26 | 39 | type oracleCase struct { | |
| 27 | 40 | E string `json:"e"` | |
| 28 | 41 | Tokens [][]any `json:"tokens"` | |
| @@ -38,6 +51,9 @@ type oracleResult struct { | |||
| 38 | 51 | K string `json:"k"` | |
| 39 | 52 | S string `json:"s"` | |
| 40 | 53 | J string `json:"j"` | |
| 54 | + | // U holds the UTF-16 code units of the string form when it contains a | |
| 55 | + | // lone surrogate and S is therefore empty. | |
| 56 | + | U []uint16 `json:"u"` | |
| 41 | 57 | } | |
| 42 | 58 | ||
| 43 | 59 | var oracleFuncInfos = []FuncInfo{ | |
| @@ -47,9 +63,9 @@ var oracleFuncInfos = []FuncInfo{ | |||
| 47 | 63 | } | |
| 48 | 64 | ||
| 49 | 65 | var oracleFuncs = map[string]Func{ | |
| 50 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | |
| 51 | - | "always": func([]Value) (Value, error) { return Boolean(true), nil }, | |
| 52 | - | "hashFiles": func(args []Value) (Value, error) { | |
| 66 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | |
| 67 | + | "always": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | |
| 68 | + | "hashFiles": func(_ context.Context, args []Value) (Value, error) { | |
| 53 | 69 | s := "hash:" | |
| 54 | 70 | for i, a := range args { | |
| 55 | 71 | if i > 0 { | |
| @@ -105,124 +121,176 @@ func loadOracle(tb testing.TB) (*oracleFile, map[string]Value, []string) { | |||
| 105 | 121 | return &of, ctx, names | |
| 106 | 122 | } | |
| 107 | 123 | ||
| 108 | - | func TestOracle(t *testing.T) { | |
| 109 | - | of, ctxValues, ctxNames := loadOracle(t) | |
| 110 | - | if len(of.Cases) < 30000 { | |
| 111 | - | t.Fatalf("oracle has only %d cases", len(of.Cases)) | |
| 124 | + | // oracleRun carries the state shared by the checks of one oracle case. | |
| 125 | + | type oracleRun struct { | |
| 126 | + | t *testing.T | |
| 127 | + | ec EvalContext | |
| 128 | + | ctxNames []string | |
| 129 | + | failures int | |
| 130 | + | c oracleCase | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (r *oracleRun) fail(format string, args ...any) { | |
| 134 | + | r.failures++ | |
| 135 | + | if r.failures <= 40 { | |
| 136 | + | e := r.c.E | |
| 137 | + | if len(e) > 200 { | |
| 138 | + | e = e[:200] + "..." | |
| 139 | + | } | |
| 140 | + | r.t.Errorf("%q: %s", e, fmt.Sprintf(format, args...)) | |
| 112 | 141 | } | |
| 113 | - | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs} | |
| 114 | - | failures := 0 | |
| 115 | - | fail := func(c oracleCase, format string, args ...any) { | |
| 116 | - | failures++ | |
| 117 | - | if failures <= 40 { | |
| 118 | - | e := c.E | |
| 119 | - | if len(e) > 200 { | |
| 120 | - | e = e[:200] + "..." | |
| 121 | - | } | |
| 122 | - | t.Errorf("%q: %s", e, fmt.Sprintf(format, args...)) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // checkLex compares the tokens and reports whether the case lexes. | |
| 145 | + | func (r *oracleRun) checkLex() bool { | |
| 146 | + | c := r.c | |
| 147 | + | tokens, err := Lex(c.E) | |
| 148 | + | if c.LexErr != nil { | |
| 149 | + | switch { | |
| 150 | + | case err == nil: | |
| 151 | + | r.fail("lex: want error %q, got none", *c.LexErr) | |
| 152 | + | case err.Error() != *c.LexErr: | |
| 153 | + | r.fail("lex error: want %q, got %q", *c.LexErr, err.Error()) | |
| 123 | 154 | } | |
| 155 | + | return false | |
| 124 | 156 | } | |
| 125 | - | var counts struct{ lexErr, parseErr, evalErr, ok, empty int } | |
| 126 | - | for _, c := range of.Cases { | |
| 127 | - | tokens, err := Lex(c.E) | |
| 128 | - | if c.LexErr != nil { | |
| 129 | - | counts.lexErr++ | |
| 130 | - | if err == nil { | |
| 131 | - | fail(c, "lex: want error %q, got none", *c.LexErr) | |
| 132 | - | } else if err.Error() != *c.LexErr { | |
| 133 | - | fail(c, "lex error: want %q, got %q", *c.LexErr, err.Error()) | |
| 157 | + | if err != nil { | |
| 158 | + | r.fail("lex: unexpected error %v", err) | |
| 159 | + | return false | |
| 160 | + | } | |
| 161 | + | if len(tokens) != len(c.Tokens) { | |
| 162 | + | r.fail("lex: %d tokens, want %d", len(tokens), len(c.Tokens)) | |
| 163 | + | return false | |
| 164 | + | } | |
| 165 | + | for i, tk := range tokens { | |
| 166 | + | w := c.Tokens[i] | |
| 167 | + | got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)} | |
| 168 | + | for j := range got { | |
| 169 | + | if got[j] != w[j] { | |
| 170 | + | r.fail("token %d field %d: got %v, want %v", i, j, got[j], w[j]) | |
| 134 | 171 | } | |
| 135 | - | continue | |
| 136 | 172 | } | |
| 137 | - | if err != nil { | |
| 138 | - | fail(c, "lex: unexpected error %v", err) | |
| 139 | - | continue | |
| 140 | - | } | |
| 141 | - | if len(tokens) != len(c.Tokens) { | |
| 142 | - | fail(c, "lex: %d tokens, want %d", len(tokens), len(c.Tokens)) | |
| 143 | - | continue | |
| 144 | - | } | |
| 145 | - | for i, tk := range tokens { | |
| 146 | - | w := c.Tokens[i] | |
| 147 | - | got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)} | |
| 148 | - | for j := range got { | |
| 149 | - | if got[j] != w[j] { | |
| 150 | - | fail(c, "token %d field %d: got %v, want %v", i, j, got[j], w[j]) | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | var gv any | |
| 154 | - | switch tk.Type { | |
| 155 | - | case TokenNumber: | |
| 156 | - | f := float64(tk.Value.(Number)) | |
| 157 | - | if f != f { | |
| 158 | - | gv = "NaN" | |
| 159 | - | } else { | |
| 160 | - | gv = jsNumberToString(f) | |
| 161 | - | } | |
| 162 | - | case TokenString: | |
| 163 | - | gv = string(tk.Value.(String)) | |
| 164 | - | } | |
| 165 | - | if gv != w[6] { | |
| 166 | - | fail(c, "token %d value: got %v, want %v", i, gv, w[6]) | |
| 173 | + | var gv any | |
| 174 | + | switch tk.Type { | |
| 175 | + | case TokenNumber: | |
| 176 | + | f := float64(tk.Value.(Number)) | |
| 177 | + | if math.IsNaN(f) { | |
| 178 | + | gv = "NaN" | |
| 179 | + | } else { | |
| 180 | + | gv = jsNumberToString(f) | |
| 167 | 181 | } | |
| 182 | + | case TokenString: | |
| 183 | + | gv = string(tk.Value.(String)) | |
| 184 | + | } | |
| 185 | + | if gv != w[6] { | |
| 186 | + | r.fail("token %d value: got %v, want %v", i, gv, w[6]) | |
| 168 | 187 | } | |
| 188 | + | } | |
| 189 | + | return true | |
| 190 | + | } | |
| 169 | 191 | ||
| 170 | - | node, err := Parse(c.E, ctxNames, oracleFuncInfos) | |
| 171 | - | if c.ParseErr != nil { | |
| 172 | - | counts.parseErr++ | |
| 173 | - | var pe *Error | |
| 174 | - | switch { | |
| 175 | - | case err == nil: | |
| 176 | - | fail(c, "parse: want error %q, got none", *c.ParseErr) | |
| 177 | - | case err.Error() != *c.ParseErr: | |
| 178 | - | fail(c, "parse error: want %q, got %q", *c.ParseErr, err.Error()) | |
| 179 | - | case func() bool { pe, _ = err.(*Error); return pe == nil }(): | |
| 180 | - | fail(c, "parse error has type %T", err) | |
| 181 | - | case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]): | |
| 182 | - | fail(c, "parse error position: got %v, want %v", pe.Pos, c.Pos) | |
| 183 | - | } | |
| 184 | - | continue | |
| 192 | + | // checkParse compares the parse outcome and returns the tree when the case | |
| 193 | + | // parses to something to evaluate. | |
| 194 | + | func (r *oracleRun) checkParse() Node { | |
| 195 | + | c := r.c | |
| 196 | + | node, err := Parse(c.E, r.ctxNames, oracleFuncInfos) | |
| 197 | + | if c.ParseErr != nil { | |
| 198 | + | var pe *Error | |
| 199 | + | switch { | |
| 200 | + | case err == nil: | |
| 201 | + | r.fail("parse: want error %q, got none", *c.ParseErr) | |
| 202 | + | case err.Error() != *c.ParseErr: | |
| 203 | + | r.fail("parse error: want %q, got %q", *c.ParseErr, err.Error()) | |
| 204 | + | case !errors.As(err, &pe): | |
| 205 | + | r.fail("parse error has type %T", err) | |
| 206 | + | case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]): | |
| 207 | + | r.fail("parse error position: got %v, want %v", pe.Pos, c.Pos) | |
| 185 | 208 | } | |
| 186 | - | if err != nil { | |
| 187 | - | fail(c, "parse: unexpected error %v", err) | |
| 188 | - | continue | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | if err != nil { | |
| 212 | + | r.fail("parse: unexpected error %v", err) | |
| 213 | + | return nil | |
| 214 | + | } | |
| 215 | + | if c.Empty { | |
| 216 | + | if node != nil { | |
| 217 | + | r.fail("parse: want empty") | |
| 189 | 218 | } | |
| 190 | - | if c.Empty { | |
| 191 | - | counts.empty++ | |
| 192 | - | if node != nil { | |
| 193 | - | fail(c, "parse: want empty") | |
| 194 | - | } | |
| 195 | - | continue | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | return node | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (r *oracleRun) checkEval(node Node) { | |
| 225 | + | c := r.c | |
| 226 | + | v, err := Evaluate(context.Background(), node, r.ec) | |
| 227 | + | if c.EvalErr != nil { | |
| 228 | + | switch { | |
| 229 | + | case err == nil: | |
| 230 | + | r.fail("eval: want error %q, got %s", *c.EvalErr, CoerceString(v)) | |
| 231 | + | case err.Error() != *c.EvalErr: | |
| 232 | + | r.fail("eval error: want %q, got %q", *c.EvalErr, err.Error()) | |
| 196 | 233 | } | |
| 234 | + | return | |
| 235 | + | } | |
| 236 | + | if err != nil { | |
| 237 | + | r.fail("eval: unexpected error %v", err) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | if k := kindName(v); k != c.R.K { | |
| 241 | + | r.fail("result kind: got %s, want %s", k, c.R.K) | |
| 242 | + | } | |
| 243 | + | if c.R.U != nil { | |
| 244 | + | if got := utf16Units(CoerceString(v)); !unitsEqual(got, c.R.U) { | |
| 245 | + | r.fail("result string units: got %04x, want %04x", got, c.R.U) | |
| 246 | + | } | |
| 247 | + | } else if s := CoerceString(v); s != c.R.S { | |
| 248 | + | r.fail("result string: got %q, want %q", s, c.R.S) | |
| 249 | + | } | |
| 250 | + | if j := ToJSON(v); j != c.R.J { | |
| 251 | + | r.fail("result JSON: got %q, want %q", j, c.R.J) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestOracle(t *testing.T) { | |
| 256 | + | of, ctxValues, ctxNames := loadOracle(t) | |
| 257 | + | if len(of.Cases) < 30000 { | |
| 258 | + | t.Fatalf("oracle has only %d cases", len(of.Cases)) | |
| 259 | + | } | |
| 260 | + | ref := of.Reference | |
| 261 | + | if !strings.HasPrefix(ref.Integrity, "sha512-") { | |
| 262 | + | t.Errorf("oracle does not record the integrity of the reference package: %+v", ref) | |
| 263 | + | } | |
| 264 | + | t.Logf("reference: %s, integrity %s, generated with node %s (V8 %s, Unicode %s)", ref.Package, ref.Integrity, ref.Node, ref.V8, ref.Unicode) | |
| 197 | 265 | ||
| 198 | - | v, err := Evaluate(node, ec) | |
| 199 | - | if c.EvalErr != nil { | |
| 266 | + | run := &oracleRun{t: t, ctxNames: ctxNames, ec: EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1}} | |
| 267 | + | var counts struct{ lexErr, parseErr, evalErr, ok, empty int } | |
| 268 | + | for _, c := range of.Cases { | |
| 269 | + | run.c = c | |
| 270 | + | switch { | |
| 271 | + | case c.LexErr != nil: | |
| 272 | + | counts.lexErr++ | |
| 273 | + | case c.ParseErr != nil: | |
| 274 | + | counts.parseErr++ | |
| 275 | + | case c.Empty: | |
| 276 | + | counts.empty++ | |
| 277 | + | case c.EvalErr != nil: | |
| 200 | 278 | counts.evalErr++ | |
| 201 | - | if err == nil { | |
| 202 | - | fail(c, "eval: want error %q, got %s", *c.EvalErr, CoerceString(v)) | |
| 203 | - | } else if err.Error() != *c.EvalErr { | |
| 204 | - | fail(c, "eval error: want %q, got %q", *c.EvalErr, err.Error()) | |
| 205 | - | } | |
| 206 | - | continue | |
| 279 | + | default: | |
| 280 | + | counts.ok++ | |
| 207 | 281 | } | |
| 208 | - | if err != nil { | |
| 209 | - | fail(c, "eval: unexpected error %v", err) | |
| 282 | + | if !run.checkLex() { | |
| 210 | 283 | continue | |
| 211 | 284 | } | |
| 212 | - | counts.ok++ | |
| 213 | - | if k := kindName(v); k != c.R.K { | |
| 214 | - | fail(c, "result kind: got %s, want %s", k, c.R.K) | |
| 215 | - | } | |
| 216 | - | if s := CoerceString(v); s != c.R.S { | |
| 217 | - | fail(c, "result string: got %q, want %q", s, c.R.S) | |
| 218 | - | } | |
| 219 | - | if j := ToJSON(v); j != c.R.J { | |
| 220 | - | fail(c, "result JSON: got %q, want %q", j, c.R.J) | |
| 285 | + | node := run.checkParse() | |
| 286 | + | if node == nil { | |
| 287 | + | continue | |
| 221 | 288 | } | |
| 289 | + | run.checkEval(node) | |
| 222 | 290 | } | |
| 223 | 291 | t.Logf("%d cases: %d lex errors, %d parse errors, %d empty, %d eval errors, %d values; %d failures", | |
| 224 | - | len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, failures) | |
| 225 | - | if failures > 0 { | |
| 226 | - | t.Fatalf("%d oracle mismatches", failures) | |
| 292 | + | len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, run.failures) | |
| 293 | + | if run.failures > 0 { | |
| 294 | + | t.Fatalf("%d oracle mismatches", run.failures) | |
| 227 | 295 | } | |
| 228 | 296 | } | |
| @@ -4,9 +4,13 @@ package expr | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "compress/gzip" | 6 | "compress/gzip" | ||
| 7 | + | "context" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 9 | + | "errors" | |||
| 8 | "fmt" | 10 | "fmt" | ||
| 11 | + | "math" | |||
| 9 | "os" | 12 | "os" | ||
| 13 | + | "strings" | |||
| 10 | "testing" | 14 | "testing" | ||
| 11 | ) | 15 | ) | ||
| 12 | 16 | ||||
| @@ -18,11 +22,20 @@ import ( | |||||
| 18 | // string coercions and toJSON output. | 22 | // string coercions and toJSON output. | ||
| 19 | 23 | ||||
| 20 | type oracleFile struct { | 24 | type oracleFile struct { | ||
| 21 | - | Reference string `json:"reference"` | 25 | + | Reference oracleReference `json:"reference"` |
| 22 | Contexts map[string]json.RawMessage `json:"contexts"` | 26 | Contexts map[string]json.RawMessage `json:"contexts"` | ||
| 23 | Cases []oracleCase `json:"cases"` | 27 | Cases []oracleCase `json:"cases"` | ||
| 24 | } | 28 | } | ||
| 25 | 29 | ||||
| 30 | + | // oracleReference records where the oracle comes from. | |||
| 31 | + | type oracleReference struct { | |||
| 32 | + | Package string `json:"package"` | |||
| 33 | + | Integrity string `json:"integrity"` | |||
| 34 | + | Node string `json:"node"` | |||
| 35 | + | V8 string `json:"v8"` | |||
| 36 | + | Unicode string `json:"unicode"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 26 | type oracleCase struct { | 39 | type oracleCase struct { | ||
| 27 | E string `json:"e"` | 40 | E string `json:"e"` | ||
| 28 | Tokens [][]any `json:"tokens"` | 41 | Tokens [][]any `json:"tokens"` | ||
| @@ -38,6 +51,9 @@ type oracleResult struct { | |||||
| 38 | K string `json:"k"` | 51 | K string `json:"k"` | ||
| 39 | S string `json:"s"` | 52 | S string `json:"s"` | ||
| 40 | J string `json:"j"` | 53 | J string `json:"j"` | ||
| 54 | + | // U holds the UTF-16 code units of the string form when it contains a | |||
| 55 | + | // lone surrogate and S is therefore empty. | |||
| 56 | + | U []uint16 `json:"u"` | |||
| 41 | } | 57 | } | ||
| 42 | 58 | ||||
| 43 | var oracleFuncInfos = []FuncInfo{ | 59 | var oracleFuncInfos = []FuncInfo{ | ||
| @@ -47,9 +63,9 @@ var oracleFuncInfos = []FuncInfo{ | |||||
| 47 | } | 63 | } | ||
| 48 | 64 | ||||
| 49 | var oracleFuncs = map[string]Func{ | 65 | var oracleFuncs = map[string]Func{ | ||
| 50 | - | "success": func([]Value) (Value, error) { return Boolean(true), nil }, | 66 | + | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, |
| 51 | - | "always": func([]Value) (Value, error) { return Boolean(true), nil }, | 67 | + | "always": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, |
| 52 | - | "hashFiles": func(args []Value) (Value, error) { | 68 | + | "hashFiles": func(_ context.Context, args []Value) (Value, error) { |
| 53 | s := "hash:" | 69 | s := "hash:" | ||
| 54 | for i, a := range args { | 70 | for i, a := range args { | ||
| 55 | if i > 0 { | 71 | if i > 0 { | ||
| @@ -105,124 +121,176 @@ func loadOracle(tb testing.TB) (*oracleFile, map[string]Value, []string) { | |||||
| 105 | return &of, ctx, names | 121 | return &of, ctx, names | ||
| 106 | } | 122 | } | ||
| 107 | 123 | ||||
| 108 | - | func TestOracle(t *testing.T) { | 124 | + | // oracleRun carries the state shared by the checks of one oracle case. |
| 109 | - | of, ctxValues, ctxNames := loadOracle(t) | 125 | + | type oracleRun struct { |
| 110 | - | if len(of.Cases) < 30000 { | 126 | + | t *testing.T |
| 111 | - | t.Fatalf("oracle has only %d cases", len(of.Cases)) | 127 | + | ec EvalContext |
| 128 | + | ctxNames []string | |||
| 129 | + | failures int | |||
| 130 | + | c oracleCase | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (r *oracleRun) fail(format string, args ...any) { | |||
| 134 | + | r.failures++ | |||
| 135 | + | if r.failures <= 40 { | |||
| 136 | + | e := r.c.E | |||
| 137 | + | if len(e) > 200 { | |||
| 138 | + | e = e[:200] + "..." | |||
| 139 | + | } | |||
| 140 | + | r.t.Errorf("%q: %s", e, fmt.Sprintf(format, args...)) | |||
| 112 | } | 141 | } | ||
| 113 | - | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs} | 142 | + | } |
| 114 | - | failures := 0 | 143 | + | |
| 115 | - | fail := func(c oracleCase, format string, args ...any) { | 144 | + | // checkLex compares the tokens and reports whether the case lexes. |
| 116 | - | failures++ | 145 | + | func (r *oracleRun) checkLex() bool { |
| 117 | - | if failures <= 40 { | 146 | + | c := r.c |
| 118 | - | e := c.E | 147 | + | tokens, err := Lex(c.E) |
| 119 | - | if len(e) > 200 { | 148 | + | if c.LexErr != nil { |
| 120 | - | e = e[:200] + "..." | 149 | + | switch { |
| 121 | - | } | 150 | + | case err == nil: |
| 122 | - | t.Errorf("%q: %s", e, fmt.Sprintf(format, args...)) | 151 | + | r.fail("lex: want error %q, got none", *c.LexErr) |
| 152 | + | case err.Error() != *c.LexErr: | |||
| 153 | + | r.fail("lex error: want %q, got %q", *c.LexErr, err.Error()) | |||
| 123 | } | 154 | } | ||
| 155 | + | return false | |||
| 124 | } | 156 | } | ||
| 125 | - | var counts struct{ lexErr, parseErr, evalErr, ok, empty int } | 157 | + | if err != nil { |
| 126 | - | for _, c := range of.Cases { | 158 | + | r.fail("lex: unexpected error %v", err) |
| 127 | - | tokens, err := Lex(c.E) | 159 | + | return false |
| 128 | - | if c.LexErr != nil { | 160 | + | } |
| 129 | - | counts.lexErr++ | 161 | + | if len(tokens) != len(c.Tokens) { |
| 130 | - | if err == nil { | 162 | + | r.fail("lex: %d tokens, want %d", len(tokens), len(c.Tokens)) |
| 131 | - | fail(c, "lex: want error %q, got none", *c.LexErr) | 163 | + | return false |
| 132 | - | } else if err.Error() != *c.LexErr { | 164 | + | } |
| 133 | - | fail(c, "lex error: want %q, got %q", *c.LexErr, err.Error()) | 165 | + | for i, tk := range tokens { |
| 166 | + | w := c.Tokens[i] | |||
| 167 | + | got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)} | |||
| 168 | + | for j := range got { | |||
| 169 | + | if got[j] != w[j] { | |||
| 170 | + | r.fail("token %d field %d: got %v, want %v", i, j, got[j], w[j]) | |||
| 134 | } | 171 | } | ||
| 135 | - | continue | |||
| 136 | } | 172 | } | ||
| 137 | - | if err != nil { | 173 | + | var gv any |
| 138 | - | fail(c, "lex: unexpected error %v", err) | 174 | + | switch tk.Type { |
| 139 | - | continue | 175 | + | case TokenNumber: |
| 140 | - | } | 176 | + | f := float64(tk.Value.(Number)) |
| 141 | - | if len(tokens) != len(c.Tokens) { | 177 | + | if math.IsNaN(f) { |
| 142 | - | fail(c, "lex: %d tokens, want %d", len(tokens), len(c.Tokens)) | 178 | + | gv = "NaN" |
| 143 | - | continue | 179 | + | } else { |
| 144 | - | } | 180 | + | gv = jsNumberToString(f) |
| 145 | - | for i, tk := range tokens { | |||
| 146 | - | w := c.Tokens[i] | |||
| 147 | - | got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)} | |||
| 148 | - | for j := range got { | |||
| 149 | - | if got[j] != w[j] { | |||
| 150 | - | fail(c, "token %d field %d: got %v, want %v", i, j, got[j], w[j]) | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | var gv any | |||
| 154 | - | switch tk.Type { | |||
| 155 | - | case TokenNumber: | |||
| 156 | - | f := float64(tk.Value.(Number)) | |||
| 157 | - | if f != f { | |||
| 158 | - | gv = "NaN" | |||
| 159 | - | } else { | |||
| 160 | - | gv = jsNumberToString(f) | |||
| 161 | - | } | |||
| 162 | - | case TokenString: | |||
| 163 | - | gv = string(tk.Value.(String)) | |||
| 164 | - | } | |||
| 165 | - | if gv != w[6] { | |||
| 166 | - | fail(c, "token %d value: got %v, want %v", i, gv, w[6]) | |||
| 167 | } | 181 | } | ||
| 182 | + | case TokenString: | |||
| 183 | + | gv = string(tk.Value.(String)) | |||
| 184 | + | } | |||
| 185 | + | if gv != w[6] { | |||
| 186 | + | r.fail("token %d value: got %v, want %v", i, gv, w[6]) | |||
| 168 | } | 187 | } | ||
| 188 | + | } | |||
| 189 | + | return true | |||
| 190 | + | } | |||
| 169 | 191 | ||||
| 170 | - | node, err := Parse(c.E, ctxNames, oracleFuncInfos) | 192 | + | // checkParse compares the parse outcome and returns the tree when the case |
| 171 | - | if c.ParseErr != nil { | 193 | + | // parses to something to evaluate. |
| 172 | - | counts.parseErr++ | 194 | + | func (r *oracleRun) checkParse() Node { |
| 173 | - | var pe *Error | 195 | + | c := r.c |
| 174 | - | switch { | 196 | + | node, err := Parse(c.E, r.ctxNames, oracleFuncInfos) |
| 175 | - | case err == nil: | 197 | + | if c.ParseErr != nil { |
| 176 | - | fail(c, "parse: want error %q, got none", *c.ParseErr) | 198 | + | var pe *Error |
| 177 | - | case err.Error() != *c.ParseErr: | 199 | + | switch { |
| 178 | - | fail(c, "parse error: want %q, got %q", *c.ParseErr, err.Error()) | 200 | + | case err == nil: |
| 179 | - | case func() bool { pe, _ = err.(*Error); return pe == nil }(): | 201 | + | r.fail("parse: want error %q, got none", *c.ParseErr) |
| 180 | - | fail(c, "parse error has type %T", err) | 202 | + | case err.Error() != *c.ParseErr: |
| 181 | - | case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]): | 203 | + | r.fail("parse error: want %q, got %q", *c.ParseErr, err.Error()) |
| 182 | - | fail(c, "parse error position: got %v, want %v", pe.Pos, c.Pos) | 204 | + | case !errors.As(err, &pe): |
| 183 | - | } | 205 | + | r.fail("parse error has type %T", err) |
| 184 | - | continue | 206 | + | case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]): |
| 207 | + | r.fail("parse error position: got %v, want %v", pe.Pos, c.Pos) | |||
| 185 | } | 208 | } | ||
| 186 | - | if err != nil { | 209 | + | return nil |
| 187 | - | fail(c, "parse: unexpected error %v", err) | 210 | + | } |
| 188 | - | continue | 211 | + | if err != nil { |
| 212 | + | r.fail("parse: unexpected error %v", err) | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | if c.Empty { | |||
| 216 | + | if node != nil { | |||
| 217 | + | r.fail("parse: want empty") | |||
| 189 | } | 218 | } | ||
| 190 | - | if c.Empty { | 219 | + | return nil |
| 191 | - | counts.empty++ | 220 | + | } |
| 192 | - | if node != nil { | 221 | + | return node |
| 193 | - | fail(c, "parse: want empty") | 222 | + | } |
| 194 | - | } | 223 | + | |
| 195 | - | continue | 224 | + | func (r *oracleRun) checkEval(node Node) { |
| 225 | + | c := r.c | |||
| 226 | + | v, err := Evaluate(context.Background(), node, r.ec) | |||
| 227 | + | if c.EvalErr != nil { | |||
| 228 | + | switch { | |||
| 229 | + | case err == nil: | |||
| 230 | + | r.fail("eval: want error %q, got %s", *c.EvalErr, CoerceString(v)) | |||
| 231 | + | case err.Error() != *c.EvalErr: | |||
| 232 | + | r.fail("eval error: want %q, got %q", *c.EvalErr, err.Error()) | |||
| 196 | } | 233 | } | ||
| 234 | + | return | |||
| 235 | + | } | |||
| 236 | + | if err != nil { | |||
| 237 | + | r.fail("eval: unexpected error %v", err) | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | if k := kindName(v); k != c.R.K { | |||
| 241 | + | r.fail("result kind: got %s, want %s", k, c.R.K) | |||
| 242 | + | } | |||
| 243 | + | if c.R.U != nil { | |||
| 244 | + | if got := utf16Units(CoerceString(v)); !unitsEqual(got, c.R.U) { | |||
| 245 | + | r.fail("result string units: got %04x, want %04x", got, c.R.U) | |||
| 246 | + | } | |||
| 247 | + | } else if s := CoerceString(v); s != c.R.S { | |||
| 248 | + | r.fail("result string: got %q, want %q", s, c.R.S) | |||
| 249 | + | } | |||
| 250 | + | if j := ToJSON(v); j != c.R.J { | |||
| 251 | + | r.fail("result JSON: got %q, want %q", j, c.R.J) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestOracle(t *testing.T) { | |||
| 256 | + | of, ctxValues, ctxNames := loadOracle(t) | |||
| 257 | + | if len(of.Cases) < 30000 { | |||
| 258 | + | t.Fatalf("oracle has only %d cases", len(of.Cases)) | |||
| 259 | + | } | |||
| 260 | + | ref := of.Reference | |||
| 261 | + | if !strings.HasPrefix(ref.Integrity, "sha512-") { | |||
| 262 | + | t.Errorf("oracle does not record the integrity of the reference package: %+v", ref) | |||
| 263 | + | } | |||
| 264 | + | t.Logf("reference: %s, integrity %s, generated with node %s (V8 %s, Unicode %s)", ref.Package, ref.Integrity, ref.Node, ref.V8, ref.Unicode) | |||
| 197 | 265 | ||||
| 198 | - | v, err := Evaluate(node, ec) | 266 | + | run := &oracleRun{t: t, ctxNames: ctxNames, ec: EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1}} |
| 199 | - | if c.EvalErr != nil { | 267 | + | var counts struct{ lexErr, parseErr, evalErr, ok, empty int } |
| 268 | + | for _, c := range of.Cases { | |||
| 269 | + | run.c = c | |||
| 270 | + | switch { | |||
| 271 | + | case c.LexErr != nil: | |||
| 272 | + | counts.lexErr++ | |||
| 273 | + | case c.ParseErr != nil: | |||
| 274 | + | counts.parseErr++ | |||
| 275 | + | case c.Empty: | |||
| 276 | + | counts.empty++ | |||
| 277 | + | case c.EvalErr != nil: | |||
| 200 | counts.evalErr++ | 278 | counts.evalErr++ | ||
| 201 | - | if err == nil { | 279 | + | default: |
| 202 | - | fail(c, "eval: want error %q, got %s", *c.EvalErr, CoerceString(v)) | 280 | + | counts.ok++ |
| 203 | - | } else if err.Error() != *c.EvalErr { | |||
| 204 | - | fail(c, "eval error: want %q, got %q", *c.EvalErr, err.Error()) | |||
| 205 | - | } | |||
| 206 | - | continue | |||
| 207 | } | 281 | } | ||
| 208 | - | if err != nil { | 282 | + | if !run.checkLex() { |
| 209 | - | fail(c, "eval: unexpected error %v", err) | |||
| 210 | continue | 283 | continue | ||
| 211 | } | 284 | } | ||
| 212 | - | counts.ok++ | 285 | + | node := run.checkParse() |
| 213 | - | if k := kindName(v); k != c.R.K { | 286 | + | if node == nil { |
| 214 | - | fail(c, "result kind: got %s, want %s", k, c.R.K) | 287 | + | continue |
| 215 | - | } | |||
| 216 | - | if s := CoerceString(v); s != c.R.S { | |||
| 217 | - | fail(c, "result string: got %q, want %q", s, c.R.S) | |||
| 218 | - | } | |||
| 219 | - | if j := ToJSON(v); j != c.R.J { | |||
| 220 | - | fail(c, "result JSON: got %q, want %q", j, c.R.J) | |||
| 221 | } | 288 | } | ||
| 289 | + | run.checkEval(node) | |||
| 222 | } | 290 | } | ||
| 223 | t.Logf("%d cases: %d lex errors, %d parse errors, %d empty, %d eval errors, %d values; %d failures", | 291 | t.Logf("%d cases: %d lex errors, %d parse errors, %d empty, %d eval errors, %d values; %d failures", | ||
| 224 | - | len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, failures) | 292 | + | len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, run.failures) |
| 225 | - | if failures > 0 { | 293 | + | if run.failures > 0 { |
| 226 | - | t.Fatalf("%d oracle mismatches", failures) | 294 | + | t.Fatalf("%d oracle mismatches", run.failures) |
| 227 | } | 295 | } | ||
| 228 | } | 296 | } | ||
M expr/parser.go +16 -8
| @@ -268,18 +268,26 @@ func (p *parser) validateFunction(ident Token, argCount int) error { | |||
| 268 | 268 | return newTokenError(ErrorUnrecognizedFunction, ident) | |
| 269 | 269 | } | |
| 270 | 270 | } | |
| 271 | - | if argCount < f.MinArgs { | |
| 272 | - | return newTokenError(ErrorTooFewParameters, ident) | |
| 273 | - | } | |
| 274 | - | if f.MaxArgs >= 0 && argCount > f.MaxArgs { | |
| 275 | - | return newTokenError(ErrorTooManyParameters, ident) | |
| 276 | - | } | |
| 277 | - | if name == "case" && argCount%2 == 0 { | |
| 278 | - | return newTokenError(ErrorEvenParameters, ident) | |
| 271 | + | if t, bad := arityError(name, f, argCount); bad { | |
| 272 | + | return newTokenError(t, ident) | |
| 279 | 273 | } | |
| 280 | 274 | return nil | |
| 281 | 275 | } | |
| 282 | 276 | ||
| 277 | + | // arityError reports whether argCount is wrong for f, and how. name is the | |
| 278 | + | // lower-cased function name: case also needs an odd number of arguments. | |
| 279 | + | func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | |
| 280 | + | switch { | |
| 281 | + | case argCount < f.MinArgs: | |
| 282 | + | return ErrorTooFewParameters, true | |
| 283 | + | case f.MaxArgs >= 0 && argCount > f.MaxArgs: | |
| 284 | + | return ErrorTooManyParameters, true | |
| 285 | + | case name == "case" && argCount%2 == 0: | |
| 286 | + | return ErrorEvenParameters, true | |
| 287 | + | } | |
| 288 | + | return 0, false | |
| 289 | + | } | |
| 290 | + | ||
| 283 | 291 | func (p *parser) primary() (Node, error) { | |
| 284 | 292 | switch { | |
| 285 | 293 | case p.match(TokenFalse): | |
| @@ -268,18 +268,26 @@ func (p *parser) validateFunction(ident Token, argCount int) error { | |||||
| 268 | return newTokenError(ErrorUnrecognizedFunction, ident) | 268 | return newTokenError(ErrorUnrecognizedFunction, ident) | ||
| 269 | } | 269 | } | ||
| 270 | } | 270 | } | ||
| 271 | - | if argCount < f.MinArgs { | 271 | + | if t, bad := arityError(name, f, argCount); bad { |
| 272 | - | return newTokenError(ErrorTooFewParameters, ident) | 272 | + | return newTokenError(t, ident) |
| 273 | - | } | |||
| 274 | - | if f.MaxArgs >= 0 && argCount > f.MaxArgs { | |||
| 275 | - | return newTokenError(ErrorTooManyParameters, ident) | |||
| 276 | - | } | |||
| 277 | - | if name == "case" && argCount%2 == 0 { | |||
| 278 | - | return newTokenError(ErrorEvenParameters, ident) | |||
| 279 | } | 273 | } | ||
| 280 | return nil | 274 | return nil | ||
| 281 | } | 275 | } | ||
| 282 | 276 | ||||
| 277 | + | // arityError reports whether argCount is wrong for f, and how. name is the | |||
| 278 | + | // lower-cased function name: case also needs an odd number of arguments. | |||
| 279 | + | func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | |||
| 280 | + | switch { | |||
| 281 | + | case argCount < f.MinArgs: | |||
| 282 | + | return ErrorTooFewParameters, true | |||
| 283 | + | case f.MaxArgs >= 0 && argCount > f.MaxArgs: | |||
| 284 | + | return ErrorTooManyParameters, true | |||
| 285 | + | case name == "case" && argCount%2 == 0: | |||
| 286 | + | return ErrorEvenParameters, true | |||
| 287 | + | } | |||
| 288 | + | return 0, false | |||
| 289 | + | } | |||
| 290 | + | ||||
| 283 | func (p *parser) primary() (Node, error) { | 291 | func (p *parser) primary() (Node, error) { | ||
| 284 | switch { | 292 | switch { | ||
| 285 | case p.match(TokenFalse): | 293 | case p.match(TokenFalse): | ||
M expr/testdata/SOURCE +7 -0
| @@ -2,3 +2,10 @@ oracle.json.gz and tojson/*.golden are outputs of @actions/expressions 0.3.61 | |||
| 2 | 2 | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) run | |
| 3 | 3 | on expressions and JSON inputs written for this repository. They are generated | |
| 4 | 4 | by ../gen/oracle.mjs and ../gen/golden.mjs. See ../../THIRD_PARTY.md. | |
| 5 | + | ||
| 6 | + | Reference package: @actions/expressions 0.3.61 | |
| 7 | + | integrity (sha512 of actions-expressions-0.3.61.tgz, computed locally in | |
| 8 | + | the format of the npm registry "dist.integrity" field): | |
| 9 | + | sha512-Ig+tXELvTjlqxQAWBQnLNn2dPpNraLMdSnT+vDzDPmJtbNUhIAH/Z9kVT8pZ8UtJi9vUGs8hwHE8JFJmbOnLEA== | |
| 10 | + | Generated with: node v25.2.1 (V8 14.1.146.11-node.14, Unicode 16.0) | |
| 11 | + | The same values are in the "reference" field of oracle.json.gz. | |
| @@ -2,3 +2,10 @@ oracle.json.gz and tojson/*.golden are outputs of @actions/expressions 0.3.61 | |||||
| 2 | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) run | 2 | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) run | ||
| 3 | on expressions and JSON inputs written for this repository. They are generated | 3 | on expressions and JSON inputs written for this repository. They are generated | ||
| 4 | by ../gen/oracle.mjs and ../gen/golden.mjs. See ../../THIRD_PARTY.md. | 4 | by ../gen/oracle.mjs and ../gen/golden.mjs. See ../../THIRD_PARTY.md. | ||
| 5 | + | ||||
| 6 | + | Reference package: @actions/expressions 0.3.61 | |||
| 7 | + | integrity (sha512 of actions-expressions-0.3.61.tgz, computed locally in | |||
| 8 | + | the format of the npm registry "dist.integrity" field): | |||
| 9 | + | sha512-Ig+tXELvTjlqxQAWBQnLNn2dPpNraLMdSnT+vDzDPmJtbNUhIAH/Z9kVT8pZ8UtJi9vUGs8hwHE8JFJmbOnLEA== | |||
| 10 | + | Generated with: node v25.2.1 (V8 14.1.146.11-node.14, Unicode 16.0) | |||
| 11 | + | The same values are in the "reference" field of oracle.json.gz. | |||
M expr/testdata/oracle.json.gz +0 -0 binary file: no text diff
binary file: no text diff
M expr/testutil_test.go +5 -2
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | - | import "testing" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // mustJSON parses JSON text into a Value or fails the test. | |
| 8 | 11 | func mustJSON(tb testing.TB, s string) Value { | |
| @@ -51,7 +54,7 @@ func evalString(tb testing.TB, src string) string { | |||
| 51 | 54 | if err != nil { | |
| 52 | 55 | return "PARSE " + err.Error() | |
| 53 | 56 | } | |
| 54 | - | v, err := Evaluate(node, EvalContext{Contexts: testContexts(tb)}) | |
| 57 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: testContexts(tb)}) | |
| 55 | 58 | if err != nil { | |
| 56 | 59 | return "ERR " + err.Error() | |
| 57 | 60 | } | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | - | import "testing" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // mustJSON parses JSON text into a Value or fails the test. | 10 | // mustJSON parses JSON text into a Value or fails the test. | ||
| 8 | func mustJSON(tb testing.TB, s string) Value { | 11 | func mustJSON(tb testing.TB, s string) Value { | ||
| @@ -51,7 +54,7 @@ func evalString(tb testing.TB, src string) string { | |||||
| 51 | if err != nil { | 54 | if err != nil { | ||
| 52 | return "PARSE " + err.Error() | 55 | return "PARSE " + err.Error() | ||
| 53 | } | 56 | } | ||
| 54 | - | v, err := Evaluate(node, EvalContext{Contexts: testContexts(tb)}) | 57 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: testContexts(tb)}) |
| 55 | if err != nil { | 58 | if err != nil { | ||
| 56 | return "ERR " + err.Error() | 59 | return "ERR " + err.Error() | ||
| 57 | } | 60 | } | ||