diff --git a/arkruntime/internal/ogenregex/GOJA_LICENSE b/arkruntime/internal/ogenregex/GOJA_LICENSE new file mode 100644 index 0000000..09c0004 --- /dev/null +++ b/arkruntime/internal/ogenregex/GOJA_LICENSE @@ -0,0 +1,15 @@ +Copyright (c) 2016 Dmitry Panov + +Copyright (c) 2012 Robert Krimen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/arkruntime/internal/ogenregex/convert.go b/arkruntime/internal/ogenregex/convert.go new file mode 100644 index 0000000..4f99dc3 --- /dev/null +++ b/arkruntime/internal/ogenregex/convert.go @@ -0,0 +1,500 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See ../validate/README.md for provenance. + +package ogenregex + +import ( + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/go-faster/errors" + "golang.org/x/text/unicode/rangetable" +) + +// Copied from dop251/goja, to avoid dependency. +// +// All rights belong to the original author. +// +// https://github.com/dop251/goja/blob/3b8a68ca89b4fa7086a4236695032e10a69b2472/parser/regexp.go#L58 + +const ( + whitespaceChars = " \f\n\r\t\v" + + "\u00a0\u1680" + + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a" + + "\u2028\u2029" + + "\u202f\u205f" + + "\u3000\ufeff" + re2Dot = "[^\r\n\u2028\u2029]" +) + +func digitValue(chr rune) int { + switch { + case '0' <= chr && chr <= '9': + return int(chr - '0') + case 'a' <= chr && chr <= 'f': + return int(chr - 'a' + 10) + case 'A' <= chr && chr <= 'F': + return int(chr - 'A' + 10) + } + return 16 // Larger than any legal digit value +} + +var ( + unicodeRangeIDNeg = rangetable.Merge(unicode.Pattern_Syntax, unicode.Pattern_White_Space) + unicodeRangeIDStartPos = rangetable.Merge(unicode.Letter, unicode.Nl, unicode.Other_ID_Start) + unicodeRangeIDContPos = rangetable.Merge( + unicodeRangeIDStartPos, + unicode.Mn, + unicode.Mc, + unicode.Nd, + unicode.Pc, + unicode.Other_ID_Continue, + ) +) + +func isIDPartUnicode(r rune) bool { + return unicode.Is(unicodeRangeIDContPos, r) && !unicode.Is(unicodeRangeIDNeg, r) || r == '\u200C' || r == '\u200D' +} + +func isIdentifierPart(chr rune) bool { + return chr == '$' || chr == '_' || chr == '\\' || + 'a' <= chr && chr <= 'z' || 'A' <= chr && chr <= 'Z' || + '0' <= chr && chr <= '9' || + chr >= utf8.RuneSelf && isIDPartUnicode(chr) +} + +// Convert converts a ECMA-262 regular expression to Go's regular expression. +// +// If the conversion is not possible, ("", false) is returned. +func Convert(pattern string) (string, bool) { + if pattern == "" { + return "", true + } + + p := parser{ + str: pattern, + length: len(pattern), + } + if err := p.parse(); err != nil { + return "", false + } + + return p.ResultString(), true +} + +type parser struct { + str string + length int + + chr rune // The current character + chrOffset int // The offset of current character + offset int // The offset after current character (may be greater than 1) + + err error + + goRegexp strings.Builder + passOffset int +} + +func (p *parser) ResultString() string { + if p.passOffset != -1 { + return p.str[:p.passOffset] + } + return p.goRegexp.String() +} + +func (p *parser) parse() error { + p.read() // Pull in the first character + p.scan() + return p.err +} + +func (p *parser) read() { + if p.offset < p.length { + p.chrOffset = p.offset + chr, width := rune(p.str[p.offset]), 1 + if chr >= utf8.RuneSelf { // !ASCII + chr, width = utf8.DecodeRuneInString(p.str[p.offset:]) + if chr == utf8.RuneError && width == 1 { + p.error(true, "Invalid UTF-8 character") + return + } + } + p.offset += width + p.chr = chr + } else { + p.chrOffset = p.length + p.chr = -1 // EOF + } +} + +func (p *parser) stopPassing() { + p.goRegexp.Grow(3 * len(p.str) / 2) + p.goRegexp.WriteString(p.str[:p.passOffset]) + p.passOffset = -1 +} + +func (p *parser) write(data []byte) { + if p.passOffset != -1 { + p.stopPassing() + } + p.goRegexp.Write(data) +} + +func (p *parser) writeByte(b byte) { + if p.passOffset != -1 { + p.stopPassing() + } + p.goRegexp.WriteByte(b) +} + +func (p *parser) writeString(s string) { + if p.passOffset != -1 { + p.stopPassing() + } + p.goRegexp.WriteString(s) +} + +func (p *parser) scan() { + for p.chr != -1 { + switch p.chr { + case '\\': + p.read() + p.scanEscape(false) + case '(': + p.pass() + p.scanGroup() + case '[': + p.scanBracket() + case ')': + p.error(true, "Unmatched ')'") + return + case '.': + p.writeString(re2Dot) + p.read() + default: + p.pass() + } + } +} + +// (...) +func (p *parser) scanGroup() { + str := p.str[p.chrOffset:] + if len(str) > 1 { // A possibility of (?= or (?! + if str[0] == '?' { + ch := str[1] + switch { + case ch == '=' || ch == '!': + p.error(false, "re2: Invalid (%s) ", p.str[p.chrOffset:p.chrOffset+2]) + return + case ch == '<': + p.error(false, "re2: Invalid (%s) ", p.str[p.chrOffset:p.chrOffset+2]) + return + case ch != ':': + p.error(true, "Invalid group") + return + } + } + } + for p.chr != -1 && p.chr != ')' { + switch p.chr { + case '\\': + p.read() + p.scanEscape(false) + case '(': + p.pass() + p.scanGroup() + case '[': + p.scanBracket() + case '.': + p.writeString(re2Dot) + p.read() + default: + p.pass() + continue + } + } + if p.chr != ')' { + p.error(true, "Unterminated group") + return + } + p.pass() +} + +// [...] +func (p *parser) scanBracket() { + str := p.str[p.chrOffset:] + if strings.HasPrefix(str, "[]") { + // [] -- Empty character class + p.writeString("[^\u0000-\U0001FFFF]") + p.offset++ + p.read() + return + } + + if strings.HasPrefix(str, "[^]") { + p.writeString("[\u0000-\U0001FFFF]") + p.offset += 2 + p.read() + return + } + + p.pass() + for p.chr != -1 { + if p.chr == ']' { + break + } else if p.chr == '\\' { + p.read() + p.scanEscape(true) + continue + } + p.pass() + } + if p.chr != ']' { + p.error(true, "Unterminated character class") + return + } + p.pass() +} + +// \... +func (p *parser) scanEscape(inClass bool) { + offset := p.chrOffset + + var length, base uint32 + switch p.chr { + case '0', '1', '2', '3', '4', '5', '6', '7': + var value int64 + size := 0 + for { + digit := int64(digitValue(p.chr)) + if digit >= 8 { + // Not a valid digit + break + } + value = value*8 + digit + p.read() + size++ + } + if size == 1 { // The number of characters read + if value != 0 { + // An invalid backreference + p.error(false, "re2: Invalid \\%d ", value) + return + } + p.passString(offset-1, p.chrOffset) + return + } + tmp := []byte{'\\', 'x', '0', 0} + if value >= 16 { + tmp = tmp[0:2] + } else { + tmp = tmp[0:3] + } + tmp = strconv.AppendInt(tmp, value, 16) + p.write(tmp) + return + + case '8', '9': + p.read() + p.error(false, "re2: Invalid \\%s ", p.str[offset:p.chrOffset]) + return + + case 'x': + p.read() + length, base = 2, 16 + + case 'u': + p.read() + if p.chr == '{' { + p.read() + length, base = 0, 16 + } else { + length, base = 4, 16 + } + + case 'b': + if inClass { + p.write([]byte{'\\', 'x', '0', '8'}) + p.read() + return + } + fallthrough + + case 'B': + fallthrough + + case 'd', 'D', 'w', 'W': + // This is slightly broken, because ECMAScript + // includes \v in \s, \S, while re2 does not + fallthrough + + case '\\': + fallthrough + + case 'f', 'n', 'r', 't', 'v': + p.passString(offset-1, p.offset) + p.read() + return + + case 'c': + p.read() + var value int64 + switch { + case 'a' <= p.chr && p.chr <= 'z': + value = int64(p.chr - 'a' + 1) + case 'A' <= p.chr && p.chr <= 'Z': + value = int64(p.chr - 'A' + 1) + default: + p.writeByte('c') + return + } + tmp := []byte{'\\', 'x', '0', 0} + if value >= 16 { + tmp = tmp[0:2] + } else { + tmp = tmp[0:3] + } + tmp = strconv.AppendInt(tmp, value, 16) + p.write(tmp) + p.read() + return + case 's': + if inClass { + p.writeString(whitespaceChars) + } else { + p.writeString("[" + whitespaceChars + "]") + } + p.read() + return + case 'S': + if inClass { + p.error(false, "S in class") + return + } + p.writeString("[^" + whitespaceChars + "]") + p.read() + return + case 'p', 'P': + p.writeByte('\\') + p.pass() + if p.chr != '{' { + p.error(false, "re2: Invalid \\p/\\P format") + return + } + p.pass() + if p.chr == '}' { + p.error(false, "re2: Empty {} is not allowed") + return + } + for { + p.pass() + if p.chr == -1 { + p.error(true, "re2: Unterminated \\p/\\P") + return + } + if p.chr == '}' { + break + } + } + p.pass() + return + default: + // $ is an identifier character, so we have to have + // a special case for it here + if p.chr == '$' || p.chr < utf8.RuneSelf && !isIdentifierPart(p.chr) { + // A non-identifier character needs escaping + p.passString(offset-1, p.offset) + p.read() + return + } + // Unescape the character for re2 + p.pass() + return + } + + // Otherwise, we're a \u.... or \x... + valueOffset := p.chrOffset + + if length > 0 { + for length := length; length > 0; length-- { + digit := uint32(digitValue(p.chr)) + if digit >= base { + // Not a valid digit + goto skip + } + p.read() + } + } else { + for p.chr != '}' && p.chr != -1 { + digit := uint32(digitValue(p.chr)) + if digit >= base { + // Not a valid digit + goto skip + } + p.read() + } + } + + switch length { + case 0, 4: + p.write([]byte{'\\', 'x', '{'}) + p.passString(valueOffset, p.chrOffset) + if length != 0 { + p.writeByte('}') + } + case 2: + p.passString(offset-1, valueOffset+2) + default: + // Should never, ever get here... + p.error(true, "re2: Illegal branch in scanEscape") + return + } + + return +skip: + p.passString(offset, p.chrOffset) +} + +func (p *parser) pass() { + if p.passOffset == p.chrOffset { + p.passOffset = p.offset + } else { + if p.passOffset != -1 { + p.stopPassing() + } + if p.chr != -1 { + p.goRegexp.WriteRune(p.chr) + } + } + p.read() +} + +func (p *parser) passString(start, end int) { + if p.passOffset == start { + p.passOffset = end + return + } + if p.passOffset != -1 { + p.stopPassing() + } + p.goRegexp.WriteString(p.str[start:end]) +} + +func (p *parser) error(fatal bool, format string, args ...interface{}) { + if p.err != nil { + return + } + p.err = errors.Errorf(format, args...) + if fatal { + p.err = errors.Wrap(p.err, "syntax") + } + p.offset = p.length + p.chr = -1 +} diff --git a/arkruntime/internal/ogenregex/ogenregex.go b/arkruntime/internal/ogenregex/ogenregex.go new file mode 100644 index 0000000..5b39004 --- /dev/null +++ b/arkruntime/internal/ogenregex/ogenregex.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See ../validate/README.md for provenance. + +// Package ogenregex provides an interface to the regex engine. +// +// JSON Schema specification prefers to use ECMA 262 regular expressions. However, Go's +// regex engine is based on RE2, which is a different engine. Also, Go's regex engine +// does not support lookbehind assertions, to ensure linear time matching. +// +// This package provides unified interface to both engines. Go's regex engine is used +// by default, but if the regex is not supported, the dlclark/regexp2 would be used. +package ogenregex + +import ( + "regexp" + "time" + + "github.com/dlclark/regexp2" + "github.com/go-faster/errors" +) + +var _ = []Regexp{ + goRegexp{}, + regexp2Regexp{}, +} + +type goRegexp struct { + orig string + exp *regexp.Regexp +} + +func (r goRegexp) Match(s []byte) (bool, error) { + return r.exp.Match(s), nil +} + +func (r goRegexp) MatchString(s string) (bool, error) { + return r.exp.MatchString(s), nil +} + +func (r goRegexp) String() string { + return r.orig +} + +type regexp2Regexp struct { + exp *regexp2.Regexp +} + +func (r regexp2Regexp) Match(s []byte) (bool, error) { + return r.exp.MatchRunes([]rune(string(s))) +} + +func (r regexp2Regexp) MatchString(s string) (bool, error) { + return r.exp.MatchString(s) +} + +func (r regexp2Regexp) String() string { + return r.exp.String() +} + +// Regexp is a regular expression interface. +type Regexp interface { + Match(s []byte) (bool, error) + MatchString(s string) (bool, error) + String() string +} + +// Compile compiles a regular expression. +// +// NOTE: this function may compile the same expression multiple times and can +// be slow. Compile the expression once and reuse it. +func Compile(exp string) (Regexp, error) { + if converted, ok := Convert(exp); ok { + if re, err := regexp.Compile(converted); err == nil { + return goRegexp{orig: exp, exp: re}, nil + } + } + re, err := regexp2.Compile(exp, regexp2.ECMAScript|regexp2.Unicode) + if err != nil { + return nil, errors.Wrap(err, "regexp2") + } + // FIXME(tdakkota): Default timeout is "forever", which may lead to DoS. + // Probably, we should make this configurable. + re.MatchTimeout = 15 * time.Second + return regexp2Regexp{re}, nil +} + +// MustCompile compiles a regular expression and panics on error. +// +// NOTE: this function may compile the same expression multiple times and can +// be slow. Compile the expression once and reuse it. +func MustCompile(exp string) Regexp { + return errors.Must(Compile(exp)) +} diff --git a/arkruntime/internal/ogenregex/ogenregex_test.go b/arkruntime/internal/ogenregex/ogenregex_test.go new file mode 100644 index 0000000..40abb11 --- /dev/null +++ b/arkruntime/internal/ogenregex/ogenregex_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package ogenregex + +import "testing" + +func TestECMAPatterns(t *testing.T) { + for _, tc := range []struct { + pattern, value string + match bool + }{ + {"^(clear_thinking|clear_tool_uses)", "clear_thinking_20250901", true}, + {"^(clear_thinking|clear_tool_uses)", "unrelated", false}, + {"^(?!examples/)", "examples/main.go", false}, + {"^(?!examples/)", "src/main.go", true}, + {"^.$", "\u2028", false}, + {"^\\s$", "\u00a0", true}, + {"^.$", "中", true}, + } { + t.Run(tc.pattern+tc.value, func(t *testing.T) { + r, err := Compile(tc.pattern) + if err != nil { + t.Fatal(err) + } + if r.String() != tc.pattern { + t.Fatal("pattern changed") + } + match, err := r.MatchString(tc.value) + if err != nil || match != tc.match { + t.Fatalf("string match=%v error=%v", match, err) + } + match, err = r.Match([]byte(tc.value)) + if err != nil || match != tc.match { + t.Fatalf("byte match=%v error=%v", match, err) + } + }) + } +} + +func TestInvalidPattern(t *testing.T) { + if _, err := Compile("("); err == nil { + t.Fatal("invalid regex accepted") + } + defer func() { + if recover() == nil { + t.Error("MustCompile did not panic") + } + }() + MustCompile("(") +} diff --git a/arkruntime/internal/validate/README.md b/arkruntime/internal/validate/README.md new file mode 100644 index 0000000..66493e4 --- /dev/null +++ b/arkruntime/internal/validate/README.md @@ -0,0 +1,29 @@ +# Generated-model validation compatibility + +This package mirrors the validator interfaces emitted by ogen v1.20.3. The +String, Int, Float, Array and constraint error implementations are adapted from +[ogen's Apache-2.0 sources](https://github.com/ogen-go/ogen/tree/v1.20.3/validate). +The original error aggregation and sentinel shim remains in `validate.go`. +Do not replace configured validators with no-op stubs: current schemas set real +bounds, string/array lengths, and patterns. + +`../ogenregex` implements the emitted regex interface using the same regexp2 +ECMAScript/Unicode fallback mode and 15-second timeout as ogen v1.20.3. It also preserves ogen's RE2 conversion fast path, including ECMAScript +Unicode whitespace and line-terminator semantics. The conversion code retains +its goja attribution; the upstream MIT notice is in `../ogenregex/GOJA_LICENSE`. The SDK depends on regexp2 +v1.11.5 and x/text v0.14.0, not the full ogen module, and keeps Go 1.20. + +Ark-apis `vendor-` rewrites both ogen `validate` and `ogenregex` imports to +these internal packages. Their files are outside the generated `model/` +directories and survive sync. Hand-written model codec tests end in +`_shim_test.go` and are also preserved by the vendor rule. Merge the SDK fix so +subsequent sync branches based on main inherit it; an unmerged branch is not the +source for later syncs. + +After any schema/generator/vendor change, run `go build ./...`, `go test ./...` +and `go vet ./...` against the **vendored SDK**, including with Go 1.20. +The package tests cover validator bounds and regex behavior independently of +generated models. Add model-constraint and recursive JSON codec regressions +alongside the corresponding generated-model sync. +Generated `gen/go` tests using the upstream ogen library alone cannot detect +an incomplete shim. New emitted symbols still require a compatibility review. diff --git a/arkruntime/internal/validate/array.go b/arkruntime/internal/validate/array.go new file mode 100644 index 0000000..ce75ac8 --- /dev/null +++ b/arkruntime/internal/validate/array.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See README.md for provenance and compatibility scope. + +package validate + +import ( + "github.com/go-faster/errors" +) + +// Array validates array length. +type Array struct { + MinLength int + MinLengthSet bool + MaxLength int + MaxLengthSet bool + UniqueItems bool +} + +// SetMaxLength sets MaxLength validation. +func (t *Array) SetMaxLength(v int) { + t.MaxLengthSet = true + t.MaxLength = v +} + +// SetMinLength sets MinLength validation. +func (t *Array) SetMinLength(v int) { + t.MinLengthSet = true + t.MinLength = v +} + +// SetUniqueItems sets UniqueItems validation. +func (t *Array) SetUniqueItems(v bool) { + t.UniqueItems = v +} + +// Set reports whether any validations are set. +func (t Array) Set() bool { + return t.MaxLengthSet || t.MinLengthSet || t.UniqueItems +} + +// ValidateLength returns error if array length v is invalid. +func (t Array) ValidateLength(v int) error { + if t.MaxLengthSet && v > t.MaxLength { + return &MaxLengthError{Len: v, MaxLength: t.MaxLength} + } + if t.MinLengthSet && v < t.MinLength { + return &MinLengthError{Len: v, MinLength: t.MinLength} + } + + return nil +} + +// UniqueItems ensures given array has no duplicates. +func UniqueItems[S ~[]T, T comparable](arr S) error { + if len(arr) < 2 { + return nil + } + for i, a := range arr { + for _, b := range arr[i+1:] { + if a == b { + return errors.Errorf("duplicate element [%d] %v", i, a) + } + } + } + return nil +} diff --git a/arkruntime/internal/validate/constraints_errors.go b/arkruntime/internal/validate/constraints_errors.go new file mode 100644 index 0000000..d353487 --- /dev/null +++ b/arkruntime/internal/validate/constraints_errors.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See README.md for provenance and compatibility scope. + +package validate + +import ( + "fmt" + + "github.com/volcengine/ark-runtime-go/arkruntime/internal/ogenregex" +) + +// MinLengthError reports that len less than minimum. +type MinLengthError struct { + Len int + MinLength int +} + +// MinLengthError implements error. +func (e *MinLengthError) Error() string { + return fmt.Sprintf("len %d less than minimum %d", e.Len, e.MinLength) +} + +// MaxLengthError reports that len greater than maximum. +type MaxLengthError struct { + Len int + MaxLength int +} + +// MaxLengthError implements error. +func (e *MaxLengthError) Error() string { + return fmt.Sprintf("len %d greater than maximum %d", e.Len, e.MaxLength) +} + +// NoRegexMatchError reports that value have no regexp match. +type NoRegexMatchError struct { + Pattern ogenregex.Regexp +} + +// MaxLengthError implements error. +func (e *NoRegexMatchError) Error() string { + return fmt.Sprintf("no regex match: %s", e.Pattern.String()) +} diff --git a/arkruntime/internal/validate/constraints_test.go b/arkruntime/internal/validate/constraints_test.go new file mode 100644 index 0000000..e3ed393 --- /dev/null +++ b/arkruntime/internal/validate/constraints_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "math" + "math/big" + "testing" + + "github.com/volcengine/ark-runtime-go/arkruntime/internal/ogenregex" +) + +func TestConstraintValues(t *testing.T) { + tests := []struct { + name string + check func() error + invalid bool + }{ + {"unicode length", func() error { return (String{MaxLength: 2, MaxLengthSet: true}).Validate("中文") }, false}, + {"max string", func() error { return (String{MaxLength: 2, MaxLengthSet: true}).Validate("中文名") }, true}, + {"min string", func() error { return (String{MinLength: 1, MinLengthSet: true}).Validate("") }, true}, + {"array lower boundary", func() error { return (Array{MinLength: 1, MinLengthSet: true}).ValidateLength(1) }, false}, + {"empty array", func() error { return (Array{MinLength: 1, MinLengthSet: true}).ValidateLength(0) }, true}, + {"long array", func() error { return (Array{MaxLength: 2, MaxLengthSet: true}).ValidateLength(3) }, true}, + {"negative integer", func() error { return (Int{Min: 0, MinSet: true}).Validate(-1) }, true}, + {"integer boundary", func() error { return (Int{Min: 0, MinSet: true}).Validate(0) }, false}, + {"exclusive integer", func() error { return (Int{Max: 5, MaxSet: true, MaxExclusive: true}).Validate(5) }, true}, + {"int multiple", func() error { return (Int{MultipleOf: 3, MultipleOfSet: true}).Validate(-6) }, false}, + {"int not multiple", func() error { return (Int{MultipleOf: 3, MultipleOfSet: true}).Validate(4) }, true}, + {"float lower", func() error { return (Float{Min: -2, MinSet: true, Max: 2, MaxSet: true}).Validate(-2) }, false}, + {"float upper", func() error { return (Float{Min: -2, MinSet: true, Max: 2, MaxSet: true}).Validate(2) }, false}, + {"float too high", func() error { return (Float{Max: 2, MaxSet: true}).Validate(2.1) }, true}, + {"float too low", func() error { return (Float{Min: -2, MinSet: true}).Validate(-2.1) }, true}, + {"exclusive float", func() error { return (Float{Min: 0, MinSet: true, MinExclusive: true}).Validate(0) }, true}, + {"float multiple", func() error { return (Float{MultipleOf: big.NewRat(1, 2), MultipleOfSet: true}).Validate(1.5) }, false}, + {"float not multiple", func() error { return (Float{MultipleOf: big.NewRat(1, 2), MultipleOfSet: true}).Validate(1.25) }, true}, + {"nan", func() error { return (Float{Min: 0, MinSet: true}).Validate(math.NaN()) }, true}, + {"infinity", func() error { return (Float{}).Validate(math.Inf(1)) }, true}, + {"pattern", func() error { + return (String{Regex: ogenregex.MustCompile("^clear_thinking")}).Validate("clear_thinking_20250901") + }, false}, + {"pattern mismatch", func() error { return (String{Regex: ogenregex.MustCompile("^clear_thinking")}).Validate("other") }, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.check(); (err != nil) != tc.invalid { + t.Fatalf("error=%v, want invalid=%v", err, tc.invalid) + } + }) + } +} diff --git a/arkruntime/internal/validate/float.go b/arkruntime/internal/validate/float.go new file mode 100644 index 0000000..a1aa0f0 --- /dev/null +++ b/arkruntime/internal/validate/float.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See README.md for provenance and compatibility scope. + +package validate + +import ( + "math" + "math/big" + "strconv" + + "github.com/go-faster/errors" + + "github.com/volcengine/ark-runtime-go/arkruntime/internal/ogenregex" +) + +// Float validates float numbers. +type Float struct { + MultipleOf *big.Rat + MultipleOfSet bool + + Min float64 + MinSet bool + MinExclusive bool + + Max float64 + MaxSet bool + MaxExclusive bool + + // Pattern constraint for validating string representation + Pattern ogenregex.Regexp +} + +// SetMultipleOf sets multipleOf validator. +func (t *Float) SetMultipleOf(rat *big.Rat) { + t.MultipleOfSet = true + t.MultipleOf = rat +} + +// SetExclusiveMinimum sets exclusive minimum value. +func (t *Float) SetExclusiveMinimum(v float64) { + t.MinExclusive = true + t.SetMinimum(v) +} + +// SetExclusiveMaximum sets exclusive maximum value. +func (t *Float) SetExclusiveMaximum(v float64) { + t.MaxExclusive = true + t.SetMaximum(v) +} + +// SetMinimum sets minimum value. +func (t *Float) SetMinimum(v float64) { + t.Min = v + t.MinSet = true +} + +// SetMaximum sets maximum value. +func (t *Float) SetMaximum(v float64) { + t.Max = v + t.MaxSet = true +} + +// SetPattern sets pattern constraint for validating string representation. +func (t *Float) SetPattern(v ogenregex.Regexp) { + t.Pattern = v +} + +// Set reports whether any validations are set. +func (t Float) Set() bool { + return t.MinSet || t.MaxSet || t.MultipleOfSet || t.Pattern != nil +} + +// Validate returns error if v does not match validation rules. +func (t Float) Validate(v float64) error { + if math.IsNaN(v) { + return errors.Errorf("value %f is not a number", v) + } + if math.IsInf(v, 0) { + return errors.Errorf("value %f is infinite", v) + } + return t.validate(v) +} + +// ValidateStringified returns error if v does not match validation rules. +func (t Float) ValidateStringified(v float64) error { + return t.validate(v) +} + +func (t Float) validate(v float64) error { + if t.MinSet && (v < t.Min || t.MinExclusive && v == t.Min) { + return errors.Errorf("value %f less than %f", v, t.Min) + } + if t.MaxSet && (v > t.Max || t.MaxExclusive && v == t.Max) { + return errors.Errorf("value %f greater than %f", v, t.Max) + } + if t.MultipleOfSet { + val := new(big.Rat).SetFloat64(v) + if !val.Quo(val, t.MultipleOf).IsInt() { + return errors.Errorf("value %f is not multiple of %s", v, t.MultipleOf.RatString()) + } + } + + // Validate pattern on string representation + if r := t.Pattern; r != nil { + str := strconv.FormatFloat(v, 'f', -1, 64) + match, err := r.MatchString(str) + if err != nil { + return errors.Wrap(err, "execute regex") + } + if !match { + return &NoRegexMatchError{ + Pattern: r, + } + } + } + + return nil +} diff --git a/arkruntime/internal/validate/int.go b/arkruntime/internal/validate/int.go new file mode 100644 index 0000000..bb54f1b --- /dev/null +++ b/arkruntime/internal/validate/int.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See README.md for provenance and compatibility scope. + +package validate + +import ( + "fmt" + + "github.com/go-faster/errors" + + "github.com/volcengine/ark-runtime-go/arkruntime/internal/ogenregex" +) + +// Int validates integers. +type Int struct { + MultipleOf uint64 + MultipleOfSet bool + + Min int64 + MinSet bool + MinExclusive bool + + Max int64 + MaxSet bool + MaxExclusive bool + + // Pattern constraint for validating string representation + Pattern ogenregex.Regexp +} + +// SetMultipleOf sets multipleOf validator. +func (t *Int) SetMultipleOf(v uint64) { + t.MultipleOfSet = true + t.MultipleOf = v +} + +// SetExclusiveMinimum sets exclusive minimum value. +func (t *Int) SetExclusiveMinimum(v int64) { + t.MinExclusive = true + t.SetMinimum(v) +} + +// SetExclusiveMaximum sets exclusive maximum value. +func (t *Int) SetExclusiveMaximum(v int64) { + t.MaxExclusive = true + t.SetMaximum(v) +} + +// SetMinimum sets minimum value. +func (t *Int) SetMinimum(v int64) { + t.Min = v + t.MinSet = true +} + +// SetMaximum sets maximum value. +func (t *Int) SetMaximum(v int64) { + t.Max = v + t.MaxSet = true +} + +// SetPattern sets pattern constraint for validating string representation. +func (t *Int) SetPattern(v ogenregex.Regexp) { + t.Pattern = v +} + +// Set reports whether any validations are set. +func (t Int) Set() bool { + return t.MinSet || t.MaxSet || t.MultipleOfSet || t.Pattern != nil +} + +// Validate returns error if v does not match validation rules. +func (t Int) Validate(v int64) error { + if t.MinSet && (v < t.Min || t.MinExclusive && v == t.Min) { + return errors.Errorf("value %d less than %d", v, t.Min) + } + if t.MaxSet && (v > t.Max || t.MaxExclusive && v == t.Max) { + return errors.Errorf("value %d greater than %d", v, t.Max) + } + // We don't care about sign when checking value using multipleOf. + if v < 0 { + v *= -1 + } + if t.MultipleOfSet && (uint64(v)%t.MultipleOf) != 0 { + return errors.Errorf("value %d is not multiple of %d", v, t.MultipleOf) + } + + // Validate pattern on string representation + if r := t.Pattern; r != nil { + str := fmt.Sprintf("%d", v) + match, err := r.MatchString(str) + if err != nil { + return errors.Wrap(err, "execute regex") + } + if !match { + return &NoRegexMatchError{ + Pattern: r, + } + } + } + + return nil +} diff --git a/arkruntime/internal/validate/string.go b/arkruntime/internal/validate/string.go new file mode 100644 index 0000000..e164677 --- /dev/null +++ b/arkruntime/internal/validate/string.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 +// +// Adapted from github.com/ogen-go/ogen v1.20.3 (Apache-2.0). +// See README.md for provenance and compatibility scope. + +package validate + +import ( + "fmt" + "unicode" + + "github.com/go-faster/errors" + + "github.com/volcengine/ark-runtime-go/arkruntime/internal/ogenregex" +) + +// String validator. +type String struct { + MinLength int + MinLengthSet bool + MaxLength int + MaxLengthSet bool + Email bool + Regex ogenregex.Regexp + Hostname bool + + // Numeric constraints for strings representing numbers + MinNumeric float64 + MinNumericSet bool + MaxNumeric float64 + MaxNumericSet bool +} + +// SetMaxLength sets maximum string length (in Unicode code points). +func (t *String) SetMaxLength(v int) { + t.MaxLengthSet = true + t.MaxLength = v +} + +// SetMinLength sets minimum string length (in Unicode code points). +func (t *String) SetMinLength(v int) { + t.MinLengthSet = true + t.MinLength = v +} + +// SetMaximumNumeric sets maximum numeric value for numeric strings. +func (t *String) SetMaximumNumeric(v float64) { + t.MaxNumericSet = true + t.MaxNumeric = v +} + +// SetMinimumNumeric sets minimum numeric value for numeric strings. +func (t *String) SetMinimumNumeric(v float64) { + t.MinNumericSet = true + t.MinNumeric = v +} + +// Set reports whether any validations are set. +func (t String) Set() bool { + return t.MaxLengthSet || t.MinLengthSet || t.Email || t.Regex != nil || t.Hostname || t.MinNumericSet || t.MaxNumericSet +} + +func (t String) checkHostname(v string) error { + if v == "" { + return errors.New("blank") + } + if len([]rune(v)) >= 255 { + return errors.New("too long") + } + for _, r := range v { + if r == '.' { + continue + } + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && (r < 'A' || r > 'Z') { + if unicode.IsSpace(r) { + return errors.Errorf("space character (%U)", r) + } + if !unicode.IsPrint(r) { + return errors.Errorf("not printable character (%U)", r) + } + return errors.Errorf("invalid character (%U)", r) + } + } + return nil +} + +func (t String) checkEmail(v string) error { + // Pretty basic validation, but should work for most cases and is not + // too strict to break things. + // + // Still better than obscure regex or std `mail.ParseAddress`. + var ( + gotAt bool + last rune + ) + for i, r := range v { + if unicode.IsSpace(r) { + return errors.Errorf("space character (%U)", r) + } + if !unicode.IsPrint(r) { + return errors.Errorf("not printable character (%U)", r) + } + + last = r + if r != '@' { + continue + } + if gotAt { + return errors.New(`got @ multiple times`) + } + if i == 0 { + return errors.New(`got @ at start`) + } + gotAt = true + } + if last == '@' { + return errors.New("@ at end") + } + if !gotAt { + return errors.New(`no @`) + } + return nil +} + +// Validate returns error if v does not match validation rules. +func (t String) Validate(v string) error { + if err := (Array{ + MinLength: t.MinLength, + MinLengthSet: t.MinLengthSet, + MaxLength: t.MaxLength, + MaxLengthSet: t.MaxLengthSet, + }).ValidateLength(len([]rune(v))); err != nil { + return err + } + if t.Email { + if err := t.checkEmail(v); err != nil { + return err + } + } + if t.Hostname { + if err := t.checkHostname(v); err != nil { + return err + } + } + if r := t.Regex; r != nil { + match, err := r.MatchString(v) + if err != nil { + return errors.Wrap(err, "execute regex") + } + if !match { + return &NoRegexMatchError{ + Pattern: r, + } + } + } + // Validate numeric constraints on string values + if t.MinNumericSet || t.MaxNumericSet { + if err := t.validateNumeric(v); err != nil { + return err + } + } + return nil +} + +func (t String) validateNumeric(v string) error { + // Parse string as float64 + var val float64 + if _, err := fmt.Sscanf(v, "%f", &val); err != nil { + return errors.Wrap(err, "parse as number") + } + + if t.MinNumericSet && val < t.MinNumeric { + return errors.Errorf("value %f less than minimum %f", val, t.MinNumeric) + } + if t.MaxNumericSet && val > t.MaxNumeric { + return errors.Errorf("value %f greater than maximum %f", val, t.MaxNumeric) + } + return nil +} diff --git a/arkruntime/internal/validate/validate.go b/arkruntime/internal/validate/validate.go index 46c3b22..1dd19bb 100644 --- a/arkruntime/internal/validate/validate.go +++ b/arkruntime/internal/validate/validate.go @@ -17,8 +17,6 @@ package validate import ( - "fmt" - "math" "strings" "github.com/go-faster/errors" @@ -60,21 +58,3 @@ func (e *Error) Error() string { } return b.String() } - -// Float is a stub for ogen's float validator. Our generated validators only -// ever construct the zero value and call Validate; the configurable -// min/max/multipleOf/pattern setters in ogen's full implementation are not -// emitted by the schemas we generate. NaN and Inf are rejected to match -// ogen's behaviour. -type Float struct{} - -// Validate returns an error if v is NaN or +/-Inf. -func (Float) Validate(v float64) error { - if math.IsNaN(v) { - return fmt.Errorf("value %f is not a number", v) - } - if math.IsInf(v, 0) { - return fmt.Errorf("value %f is infinite", v) - } - return nil -} diff --git a/go.mod b/go.mod index ecdbfe2..144766e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/volcengine/ark-runtime-go go 1.20 require ( + github.com/dlclark/regexp2 v1.11.5 github.com/go-faster/errors v0.7.1 github.com/go-faster/jx v1.2.0 github.com/google/uuid v1.6.0 @@ -13,6 +14,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/volcengine/volc-sdk-golang v1.0.23 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.14.0 gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 5f83c6e..32dfa3e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= @@ -71,9 +73,11 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=