Files
spinifex/internal/awsmodel/pattern.go
T
Julian Sommer b3be052311 test: remove integration conformance suite artefacts
Clear the warnings the conformance suite caused itself, so what remains is a real divergence from AWS:

- Seed conditionally required members into the base request of RDS CreateDBInstance and RestoreDBInstanceFromDBSnapshot, ECS CreateCapacityProvider and CreateService, and EC2 CreateCapacityReservation.
- Give loosely modelled members values AWS accepts: ELBv2 ProtocolVersion and Matcher.HttpCode, EKS policyArn, addonName and access-entry type, EC2 SourceRegion and STS AccessKeyId.
- Import a CA-signed certificate, key and chain in ACM ImportCertificate.
- Rewrite ACM's lookahead domain pattern as RE2 so RequestCertificate is generated, and give the integration ACM service a tenant CA permitting example.com.
- Run the service daemon-lite behind the cross-tenant boundary test.
- Accept ELBv2's common parameter validation errors in responses.
2026-09-29 11:05:22 +10:00

255 lines
6.8 KiB
Go

package awsmodel
import (
"regexp"
"regexp/syntax"
"strings"
"sync"
"unicode/utf8"
)
// maxGeneratedLength bounds generated strings, so a constraint such as a
// 128 KiB policy document maximum does not produce a request that size.
const maxGeneratedLength = 1 << 16
// patternSampler produces strings matching a model pattern. Smithy patterns
// are not implicitly anchored, so a match anywhere in the value satisfies one.
type patternSampler struct {
re *regexp.Regexp
anchored *regexp.Regexp
syntax *syntax.Regexp
mu sync.Mutex
samples map[[2]int]sampleResult
}
type sampleResult struct {
value string
ok bool
}
type samplerResult struct {
sampler *patternSampler
err error
}
// samplers caches one sampler per pattern: every request case regenerates
// its input, and the same patterns recur across operations.
var samplers sync.Map
// javaEscape matches the \uXXXX escapes AWS patterns use, which Go spells \x{XXXX}.
var javaEscape = regexp.MustCompile(`\\u([0-9A-Fa-f]{4})`)
func newPatternSampler(pattern string) (*patternSampler, error) {
if cached, ok := samplers.Load(pattern); ok {
if result, ok := cached.(samplerResult); ok {
return result.sampler, result.err
}
}
sampler, err := compilePatternSampler(pattern)
samplers.Store(pattern, samplerResult{sampler: sampler, err: err})
return sampler, err
}
// re2Equivalents rewrite model patterns that use lookahead, which Go regexp
// lacks, as patterns matching the same strings.
var re2Equivalents = map[string]string{
// ACM DomainNameString: labels start and end alphanumeric, the last has two
// or more characters, with an optional leading wildcard label.
`^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`: `^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9]$`,
}
// re2Pattern spells a model pattern in Go regexp syntax.
func re2Pattern(pattern string) string {
if equivalent, ok := re2Equivalents[pattern]; ok {
return equivalent
}
return javaEscape.ReplaceAllString(pattern, `\x{$1}`)
}
func compilePatternSampler(pattern string) (*patternSampler, error) {
pattern = re2Pattern(pattern)
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
parsed, err := syntax.Parse(pattern, syntax.Perl)
if err != nil {
return nil, err
}
anchored, err := regexp.Compile(`^(?:` + pattern + `)$`)
if err != nil {
return nil, err
}
return &patternSampler{re: re, anchored: anchored, syntax: parsed.Simplify(), samples: map[[2]int]sampleResult{}}, nil
}
// sample returns a string fully matching the pattern with a rune length in
// [minLength, maxLength], expanding every repetition the same number of times
// and searching for that count, since the length only grows with it.
func (p *patternSampler) sample(minLength, maxLength int) (string, bool) {
key := [2]int{minLength, maxLength}
p.mu.Lock()
defer p.mu.Unlock()
if cached, ok := p.samples[key]; ok {
return cached.value, cached.ok
}
value, ok := p.search(minLength, maxLength)
p.samples[key] = sampleResult{value: value, ok: ok}
return value, ok
}
func (p *patternSampler) search(minLength, maxLength int) (string, bool) {
lengthAt := func(repeat int) (string, int, bool) {
var builder strings.Builder
if !writeSample(&builder, p.syntax, repeat, maxLength+1) {
return "", 0, false
}
value := builder.String()
return value, utf8.RuneCountInString(value), true
}
value, length, ok := lengthAt(0)
if !ok {
return "", false
}
repeat := 0
if length < minLength {
// Double until long enough, then bisect for the smallest count.
low, high := 0, 1
for {
_, length, ok = lengthAt(high)
if !ok {
return "", false
}
if length >= minLength {
break
}
if high >= maxGeneratedLength {
return "", false
}
low, high = high, high*2
}
for low+1 < high {
middle := (low + high) / 2
if _, length, _ = lengthAt(middle); length >= minLength {
high = middle
} else {
low = middle
}
}
repeat = high
}
// A pattern whose fixed parts need a larger count than the minimum does
// can still fit a few counts higher.
for attempt := range 4 {
value, length, ok = lengthAt(repeat + attempt)
if !ok || length > maxLength {
return "", false
}
if length >= minLength && p.fullMatch(value) {
return value, true
}
}
return "", false
}
// fullMatch reports whether the whole value matches the pattern. Generated
// values are held to this, stricter than Smithy's unanchored match, so a value
// like "/a" is not taken for a path because it contains a "/".
func (p *patternSampler) fullMatch(value string) bool {
return p.anchored.MatchString(value)
}
func writeSample(builder *strings.Builder, re *syntax.Regexp, repeat, limit int) bool {
// Past the limit the value is already too long; the caller only needs to
// see that.
if builder.Len() > limit*utf8.UTFMax {
return true
}
switch re.Op {
case syntax.OpNoMatch:
return false
case syntax.OpLiteral:
for _, r := range re.Rune {
builder.WriteRune(r)
}
case syntax.OpCharClass:
r, ok := classRune(re.Rune)
if !ok {
return false
}
builder.WriteRune(r)
case syntax.OpAnyChar, syntax.OpAnyCharNotNL:
builder.WriteRune('a')
case syntax.OpCapture:
return writeSample(builder, re.Sub[0], repeat, limit)
case syntax.OpConcat:
for _, sub := range re.Sub {
if !writeSample(builder, sub, repeat, limit) {
return false
}
}
case syntax.OpAlternate:
for _, sub := range re.Sub {
var alternative strings.Builder
if writeSample(&alternative, sub, repeat, limit) {
builder.WriteString(alternative.String())
return true
}
}
return false
case syntax.OpStar, syntax.OpPlus, syntax.OpQuest, syntax.OpRepeat:
count := repetitions(re, repeat)
for range count {
if !writeSample(builder, re.Sub[0], repeat, limit) {
return false
}
}
}
// Anchors, word boundaries and empty matches contribute no characters.
return true
}
func repetitions(re *syntax.Regexp, repeat int) int {
low, high := 0, -1
switch re.Op {
case syntax.OpPlus:
low = 1
case syntax.OpQuest:
high = 1
case syntax.OpRepeat:
low, high = re.Min, re.Max
}
count := max(repeat, low)
if high >= 0 {
count = min(count, high)
}
return count
}
// classRune picks a readable member of a character class: a lower-case
// letter, then a digit, then any printable ASCII, then the first rune.
func classRune(ranges []rune) (rune, bool) {
if len(ranges) == 0 {
return 0, false
}
for _, preferred := range []string{"abcdefghijklmnopqrstuvwxyz", "0123456789", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-_.!#$%&'()*+,/:;<=>?@[]^`{|}~"} {
for _, r := range preferred {
if inClass(ranges, r) {
return r, true
}
}
}
return ranges[0], true
}
func inClass(ranges []rune, r rune) bool {
for i := 0; i+1 < len(ranges); i += 2 {
if r >= ranges[i] && r <= ranges[i+1] {
return true
}
}
return false
}