pkg/models/callshape.go
Rama main · réplica de lectura del repositorio original.
package models
import (
"encoding/json"
"net"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// The shape of a call keploy could not answer, as the proven model's evidence
// names it (design v14 §2.1, §5.3): what the call was, without the values its
// URL or statement held, as far as their look, or the call itself, tells them
// apart (PathShape, SQLShape). The agent keeps a miss in its shape; so do the
// reason it answers a test with and the JUnit file's proven evidence lines.
// The report file and `keploy report` keep the full summary, as they keep
// full bodies.
// MissShape is a miss's summary in its shape (CallShape), with every URL path
// segment that a test variable's place names (url.path[<n>]) written {}: a
// place one of its field diffs names, or one in VariablePlaces (the place a
// variable miss names, one the closest mock or a mock on the call's route
// declares, or one holding a value the test's variables have). The miss says
// a value the app sent is there, whatever its look.
//
// A MongoDB miss's shape also names, from the request the matcher rendered
// (ReceivedReq), the database the command ran in and its filter's keys
// (mongoMissShape).
func MissShape(m UnmatchedCall) string {
if isMongoCall(m.Protocol) {
return mongoMissShape(m)
}
shape := CallShape(m.Protocol, m.ActualSummary)
if !IsPathProtocol(m.Protocol) || (len(m.FieldDiffs) == 0 && len(m.VariablePlaces) == 0) {
return shape
}
places := append([]string(nil), m.VariablePlaces...)
for _, d := range m.FieldDiffs {
places = append(places, d.Path)
}
return holePathPlaces(shape, places)
}
// CallShape is a miss's summary in its shape: for HTTP and gRPC the method
// and the target's shape ("GET /users/{}/orders", "/pkg.v1.Service/Method"),
// for SQL the statement with its literals written "?" (SQLShape), for
// MongoDB the command and the namespace it runs on, each name by KeyShape
// (mongoShape: "find on app_config_audits"), and "" for any other protocol,
// whose summary may hold the values it sent (a cache key, a document) and
// which keploy has no shape for. An HTTP, gRPC or MongoDB shape is its own
// shape; a SQL statement cut at its bound may shape again shorter.
func CallShape(protocol, summary string) string {
switch {
case IsPathProtocol(protocol):
return methodTargetShape(summary)
case isSQLCall(protocol):
return SQLShape(summary)
case isMongoCall(protocol):
return mongoShape(summary)
}
return ""
}
// isHTTPProtocol reports whether a miss's protocol is HTTP, whose summary is
// a method and a path: "HTTP", "HTTPS", "HTTP2" or "HTTP/2".
func isHTTPProtocol(protocol string) bool {
for _, p := range []string{"HTTP", "HTTPS", "HTTP2", "HTTP/2"} {
if strings.EqualFold(protocol, p) {
return true
}
}
return false
}
// IsPathPlace reports whether a variable's place is a URL path segment
// (url.path[<n>]), the only places a call's shape names.
func IsPathPlace(place string) bool {
_, ok := pathPlaceIndex(place)
return ok
}
// IsPathProtocol reports whether a miss's summary is a method and a URL, or
// a gRPC full method: HTTP's and gRPC's.
func IsPathProtocol(protocol string) bool {
return isHTTPProtocol(protocol) || strings.EqualFold(protocol, "gRPC")
}
// MockURLShape is a recorded HTTP mock's URL as evidence names it: its host
// and port, as a call's destination names them (no credentials), and its
// path's shape (PathShape), read as a variable's place reads it (the parsed
// URL's escaped path and query), with every path segment the
// mock declares for a test variable (DeclaredPathPlaces) written {}, whatever
// its look. "" for a mock with no HTTP request; {} for a URL that does not
// parse.
func MockURLShape(m *Mock) string {
if m == nil || m.Spec.HTTPReq == nil {
return ""
}
u, err := url.Parse(strings.TrimSpace(m.Spec.HTTPReq.URL))
if err != nil {
return pathHole
}
// A place counts segments after the path's leading slash; a relative
// URL's path has none, so it is counted as if it had one.
p := u.EscapedPath()
relative := p != "" && p[0] != '/'
if relative {
p = "/" + p
}
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
shape := holePathPlaces(PathShape(p), DeclaredPathPlaces(m))
if relative {
shape = strings.TrimPrefix(shape, "/")
}
return u.Host + shape
}
// DeclaredPathPlaces are the URL path places (url.path[<n>]) a mock declares
// for test variables, as bind or use places, without repeats, in the order of
// the variables' names: the segments of a call to its route that hold the
// application's values.
func DeclaredPathPlaces(m *Mock) []string {
if m == nil {
return nil
}
var out []string
add := func(p string) {
if IsPathPlace(p) && !slices.Contains(out, p) {
out = append(out, p)
}
}
names := make([]string, 0, len(m.Spec.Variables))
for name := range m.Spec.Variables {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
v := m.Spec.Variables[name]
add(v.Bind)
for _, p := range v.Use {
add(p)
}
}
return out
}
// FieldShapes are the names of the fields a call differs in (row S1's
// Differs), each in its shape (FieldShape), once, for a reason that names
// them.
func FieldShapes(fields []string) []string {
if len(fields) == 0 {
return nil
}
out := make([]string, 0, len(fields))
for _, f := range fields {
if shape := FieldShape(f); !slices.Contains(out, shape) {
out = append(out, shape)
}
}
return out
}
// FieldShape is a compared field's name as a reason names it: each
// dot-separated part by PathShape's segment rule, with its indexes ([], [0],
// up to four digits) kept, so a part that may be a value the app sent (a map
// key that is an ID, an email address, a token) is {}. By that rule any key
// that does not read as a name is {} too (line1, $set, iOS, café), and an
// API version (v1) is kept. A header's name is kept whole: it names the
// field (header.Authorization). A raw dotted key is shaped part by part
// (alice.smith@example.com keeps "alice"), so a producer shapes its keys
// first (KeyShape), as the strict comparison does. An SQL call's place name
// is kept whole too (sqlPlace: bind.$1, literal.3): a position keploy counts
// in the statement, not a value the app sent.
func FieldShape(f string) string {
if name, ok := strings.CutPrefix(f, "header."); ok && name != "" && !strings.Contains(name, ".") {
return f
}
if sqlPlace.MatchString(f) {
return f
}
parts := strings.Split(f, ".")
for i, p := range parts {
base, index := p, ""
if j := strings.IndexByte(p, '['); j >= 0 && fieldIndexes.MatchString(p[j:]) {
base, index = p[:j], p[j:]
}
parts[i] = KeyShape(base) + index
}
return strings.Join(parts, ".")
}
// fieldIndexes are a field part's array indexes: [], [0], [3][].
var fieldIndexes = regexp.MustCompile(`^(\[[0-9]{0,4}\])+$`)
// sqlPlace is a whole field that is an SQL call's place name, as the
// Postgres parser names a statement's places: bind.$N, its Nth bound
// parameter, and literal.N, the Nth constant of its text, each counted from
// 1, up to five digits (a statement binds at most 65535 parameters).
var sqlPlace = regexp.MustCompile(`^(bind\.\$|literal\.)[1-9][0-9]{0,4}$`)
// KeyShape is an object key, a form key or a query name as a field's name
// holds it: itself when it reads as a name (PathShape's segment rule), {}
// when it may be a value the app sent.
func KeyShape(k string) string {
return pathSegmentShape(k)
}
// isSQLCall reports whether a miss's protocol speaks SQL, whose summary is
// the statement keploy saw.
func isSQLCall(protocol string) bool {
p := strings.ToLower(protocol)
return strings.Contains(p, "sql") || strings.Contains(p, "postgres")
}
// isMongoCall reports whether a miss's protocol is MongoDB's, whose summary
// is its command and the collection it names: "find on users".
func isMongoCall(protocol string) bool {
return strings.EqualFold(protocol, "MongoDB") || strings.EqualFold(protocol, "Mongo")
}
// mongoMaxKeys bounds the filter keys a MongoDB shape names; past it the
// shape says "…".
const mongoMaxKeys = 8
// mongoShape is a MongoDB miss's summary in its shape: "<command>", or
// "<command> on <namespace>", then "filter <key>,<key>" when the summary
// names its filter's keys, as a shape of one does (mongoMissShape). The
// command, each dot-separated part of the namespace ("api.app_config_audits"
// or a collection alone) and of each key is kept when it reads as a name
// (KeyShape), else {}: a tenant's database or collection named by a value
// (tenant_42), an ID used as a key. The legacy command pseudo-collection
// $cmd is a name. Anything else the summary holds is left out, so what the
// shape names is only ever a name.
func mongoShape(summary string) string {
f := strings.Fields(summary)
if len(f) == 0 {
return ""
}
out := KeyShape(f[0])
i := 1
if i+1 < len(f) && f[i] == "on" {
out += " on " + mongoNameShape(f[i+1])
i += 2
}
if i+1 < len(f) && f[i] == "filter" {
out += " filter " + strings.Join(mongoKeysShape(strings.Split(f[i+1], ",")), ",")
}
return out
}
// mongoMissShape is a MongoDB miss's shape (mongoShape) naming, when the
// request the matcher rendered for it (ReceivedReq, its OP_MSG command as
// "cmd") holds them, the database the command ran in ("$db": the namespace
// "<db>.<collection>") and the keys of its filter ("filter", or "query" for
// count, distinct and findAndModify), in their order by name: "find on
// api.app_config_audits filter appId,name". A miss with no rendered request
// (no closest mock), or one that does not read as a command, is shaped by
// its summary alone; so are a write's per-statement filters (delete's and
// update's q) and an aggregate's $match, which name no "filter".
func mongoMissShape(m UnmatchedCall) string {
shape := mongoShape(m.ActualSummary)
var body struct {
Cmd map[string]json.RawMessage `json:"cmd"`
}
if m.ReceivedReq == "" || json.Unmarshal([]byte(m.ReceivedReq), &body) != nil || body.Cmd == nil {
return shape
}
f := strings.Fields(m.ActualSummary)
if len(f) == 0 {
return shape
}
var command, coll, db string
command = f[0]
if json.Unmarshal(body.Cmd[command], &coll) != nil || coll == "" {
return shape
}
_ = json.Unmarshal(body.Cmd["$db"], &db)
ns := coll
if db != "" {
ns = db + "." + coll
}
out := KeyShape(command) + " on " + mongoNameShape(ns)
var filter map[string]json.RawMessage
for _, k := range []string{"filter", "query"} {
if json.Unmarshal(body.Cmd[k], &filter) == nil && len(filter) > 0 {
break
}
filter = nil
}
if len(filter) > 0 {
keys := make([]string, 0, len(filter))
for k := range filter {
keys = append(keys, k)
}
slices.Sort(keys)
if len(keys) > mongoMaxKeys {
keys = append(keys[:mongoMaxKeys], "…")
}
out += " filter " + strings.Join(mongoKeysShape(keys), ",")
}
return out
}
// mongoNameShape is a MongoDB name (a namespace, a collection, a dotted
// key) with each dot-separated part kept when it reads as a name (KeyShape),
// else {}, and $cmd kept.
func mongoNameShape(s string) string {
parts := strings.Split(s, ".")
for i, p := range parts {
if p != "$cmd" {
parts[i] = KeyShape(p)
}
}
return strings.Join(parts, ".")
}
// mongoKeysShape are filter keys each in its shape (mongoNameShape), once,
// in their order, with a last "…" (keys left out) kept and an empty key {}.
func mongoKeysShape(keys []string) []string {
out := make([]string, 0, len(keys))
for i, k := range keys {
shape := mongoNameShape(k)
switch {
case k == "…" && i == len(keys)-1:
shape = k
case k == "":
shape = pathHole
}
if !slices.Contains(out, shape) {
out = append(out, shape)
}
}
return out
}
// PathShape is a URL path as the proven model's evidence keeps it (design v14
// §2.1, §5.3): its segments that read as names, every other one written {},
// and the query's names with every value written {}.
// "/users/alice@example.com/orders/12345?expand=items" is
// "/users/{}/orders/{}?expand={}". A request target carries no fragment, so
// a '#' is a decoded %23, part of its segment; a '?' starts the query, also
// one decoded from %3F.
//
// A segment is kept only when it reads as a name. It is split into parts at
// '.', '-', '_', '~' and ':' ("pkg.v1.Service", "user-profile",
// "projects:batchGet"), and each part must be an API version (v1, v2beta1),
// or letters only, not eight or more of the letters a-f alone, and either in
// one case and shorter than 32, or camelCase words (pathCamelWords:
// "AccessSecretVersion", "batchGet", "XMLHttpRequest"). Every other segment is
// {}: a number, a UUID, a hash, an email address, a token or ID with a digit
// in it (acct_42, a1b2), most random tokens in both cases
// (cus_NffrFeUfNVaHib), one holding any other character (base64's '+' and
// '=', '%', '@', '#', a space, non-ASCII), and one longer than 64 bytes. A
// name with a digit (oauth2) is {} too: by its look it cannot be told from
// an ID.
//
// The shape is what a segment's look says. A value of letters in one case (a
// user name, a slug, a lower-case token, a code like USD), or one that reads
// as camelCase words (about one in nine random tokens in both cases of 8
// letters does, one in thirty-five of 16), cannot be told from a name, and is
// kept, unless the call names its place as a value (holePathPlaces).
//
// Query names follow the segment rule; a component with no '=' (a flag or a
// bare value, as in ?1697040000) is {}. A shape is its own shape: an
// already-templated path ("/users/{}/orders") is kept as it is, and a named
// placeholder ("/users/{id}") is written {} like any other segment that
// holds a brace.
func PathShape(p string) string {
p, query, hasQuery := strings.Cut(p, "?")
segs := strings.Split(p, "/")
for i, s := range segs {
segs[i] = pathSegmentShape(s)
}
out := strings.Join(segs, "/")
if !hasQuery {
return out
}
var params []string
for _, kv := range strings.Split(query, "&") {
if kv == "" {
continue
}
k, _, hasValue := strings.Cut(kv, "=")
if !hasValue {
params = append(params, pathHole)
continue
}
params = append(params, pathSegmentShape(k)+"="+pathHole)
}
if len(params) == 0 {
return out
}
return out + "?" + strings.Join(params, "&")
}
// Bounds of a segment PathShape keeps.
const (
pathMaxSegment = 64
pathMaxPart = 32
pathMinHex = 8
)
// pathHole is what PathShape writes for a variable segment or value.
const pathHole = "{}"
// apiVersionPart is an API version: v1, V2, v10, v1beta1, v2alpha, v3rc2.
var apiVersionPart = regexp.MustCompile(`^[vV][0-9]{1,2}((alpha|beta|rc)[0-9]{0,2})?$`)
// pathSegmentShape is s when it reads as a name (PathShape), else {}.
func pathSegmentShape(s string) string {
if s == "" || s == pathHole {
return s
}
if len(s) > pathMaxSegment {
return pathHole
}
for i := 0; i < len(s); i++ {
if !isASCIILetter(s[i]) && !isDigitByte(s[i]) && !isPathNameSeparator(s[i]) {
return pathHole
}
}
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r < 0x80 && isPathNameSeparator(byte(r)) }) {
if !pathNamePart(part) {
return pathHole
}
}
return s
}
// pathNamePart reports whether one part of a segment reads as a name: an API
// version, or letters only, not pathMinHex or more of the letters a-f alone,
// and either in one case and shorter than pathMaxPart, or camelCase words.
func pathNamePart(p string) bool {
if apiVersionPart.MatchString(p) {
return true
}
hex, upper, lower := true, false, false
for i := 0; i < len(p); i++ {
c := p[i]
if !isASCIILetter(c) {
return false
}
if !strings.ContainsRune("abcdefABCDEF", rune(c)) {
hex = false
}
if isUpperByte(c) {
upper = true
} else {
lower = true
}
}
switch {
case hex && len(p) >= pathMinHex:
return false
case upper && lower:
return pathCamelWords(p)
}
return len(p) < pathMaxPart
}
// pathShortWords are the words of fewer than three letters a camelCase name
// is made of (GetUserById, ConvertToJson).
var pathShortWords = map[string]bool{"by": true, "to": true, "of": true, "on": true, "in": true, "at": true, "is": true, "or": true, "as": true, "if": true, "id": true, "up": true, "an": true}
// pathCamelWords reports whether p, letters in both cases, reads as words in
// camelCase or PascalCase: each word a capital and lower-case letters, or a
// run of capitals (an acronym: XMLHttpRequest is XML, Http, Request), and
// only the first word may start lower-case. A word with lower-case letters
// is three letters or more, or one of pathShortWords; an acronym is two
// capitals or more. Most random tokens in both cases (NffrFeUfNVaHib,
// XkTqPzRm) break into words of one or two letters, so they are no name.
func pathCamelWords(p string) bool {
for i := 0; i < len(p); {
start, capitals := i, 0
for i < len(p) && isUpperByte(p[i]) {
i++
capitals++
}
if capitals > 1 && i < len(p) {
// Capitals, then lower-case: the last capital starts the next
// word, and the ones before it are an acronym.
i--
if i-start < 2 {
return false
}
continue
}
for i < len(p) && !isUpperByte(p[i]) {
i++
}
w := p[start:i]
switch {
case capitals == len(w):
if len(w) < 2 {
return false
}
case len(w) < 3 && !pathShortWords[strings.ToLower(w)]:
return false
}
}
return true
}
// absoluteURL is a target that starts with a scheme: "http://host/path".
var absoluteURL = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`)
// targetShape is an HTTP or gRPC call's target as evidence keeps it: a path
// (it starts with '/') is its shape (PathShape), query names included. An
// absolute URL keeps only its path and query, shaped; one that does not parse
// is {}. A target that names its host first, as a dependency row does
// ("pay.internal:8443/cards/42"), keeps the host and port, as a call's
// destination keeps them, when they read as a host (hostPort). Any other
// target is shaped as a path without its leading slash.
func targetShape(t string) string {
if t == "" || t[0] == '/' {
return PathShape(t)
}
if absoluteURL.MatchString(t) {
if u, err := url.Parse(t); err == nil {
p := u.EscapedPath()
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
return PathShape(p)
}
return pathHole
}
host, rest := t, ""
if i := strings.IndexByte(t, '/'); i >= 0 {
host, rest = t[:i], t[i:]
}
if hostPort(host) {
return host + PathShape(rest)
}
return PathShape(t)
}
// hostPort reports whether h reads as a host, with an optional port from 1 to
// 65535: an IPv4 address, a bracketed IPv6 address, or a name that is dotted
// or has a port (pay.internal, db:5432), whose every label holds a letter and
// is letters, digits and '-', and whose last label is letters only. A token
// with no dot and no port (12345, a UUID), credentials (user@host), and a
// dotted value whose last label has a digit or whose labels lack a letter
// (a1b2.c3d4, 4111.1111, most JWTs) are no host; one that passes those
// checks (acct42.example) is read as a host.
func hostPort(h string) bool {
host, hasPort := h, false
if i := strings.LastIndexByte(h, ':'); i >= 0 && !strings.HasSuffix(h, "]") {
host, hasPort = h[:i], true
port, n := h[i+1:], 0
if port == "" || len(port) > 5 {
return false
}
for j := 0; j < len(port); j++ {
if !isDigitByte(port[j]) {
return false
}
n = n*10 + int(port[j]-'0')
}
if n < 1 || n > 65535 {
return false
}
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
ip := net.ParseIP(host[1 : len(host)-1])
return ip != nil && ip.To4() == nil
}
if ip := net.ParseIP(host); ip != nil {
return ip.To4() != nil
}
labels := strings.Split(host, ".")
if len(labels) < 2 && !hasPort {
return false
}
for i, label := range labels {
if label == "" || len(label) > 63 {
return false
}
letter := false
for j := 0; j < len(label); j++ {
c := label[j]
switch {
case isASCIILetter(c):
letter = true
case (isDigitByte(c) || c == '-') && i < len(labels)-1:
default:
return false
}
}
if !letter {
return false
}
}
return true
}
// methodTargetShape is an HTTP or gRPC call's summary in its shape: "<method>
// <target>" as its method and its target's shape (targetShape), a method
// alone as itself, and a target alone (a gRPC full method,
// "/pkg.Service/Method") as its shape. The target is all that follows the
// method, to the end: a decoded path can hold a space, even a last one, and
// the segment that holds it is {}. A summary whose first word is neither a
// method (isMethodWord) nor a target is "": keploy has no shape for it.
func methodTargetShape(summary string) string {
s := strings.TrimLeftFunc(summary, unicode.IsSpace)
if strings.TrimSpace(s) == "" {
return ""
}
if s[0] == '/' {
return targetShape(s)
}
i := strings.IndexFunc(s, unicode.IsSpace)
if i < 0 {
if isMethodWord(s) {
return s
}
return targetShape(s)
}
if !isMethodWord(s[:i]) {
return ""
}
if t := targetShape(strings.TrimLeftFunc(s[i:], unicode.IsSpace)); t != "" {
return s[:i] + " " + t
}
return s[:i]
}
// isMethodWord reports whether s is a call's method: capital letters and '-'
// (GET, M-SEARCH), at most sixteen, or gRPC.
func isMethodWord(s string) bool {
if s == "gRPC" {
return true
}
if s == "" || len(s) > 16 || !isUpperByte(s[0]) {
return false
}
for i := 0; i < len(s); i++ {
if !isUpperByte(s[i]) && s[i] != '-' {
return false
}
}
return true
}
// holePathPlaces is a call's shape with every URL path segment one of places
// names (url.path[<n>], counted from 0 after the leading slash, as a test
// variable's place is) written {}: the call says a value the app sent is
// there, whatever its look. Every other place is no path segment. A place
// counts the segments of the escaped path, so the shape must be of the
// escaped path too, as a miss at a variable's place names it: decoded, a %2F
// would shift the count.
func holePathPlaces(shape string, places []string) string {
start := strings.IndexByte(shape, '/')
if start < 0 {
return shape
}
end := len(shape)
if q := strings.IndexByte(shape[start:], '?'); q >= 0 {
end = start + q
}
segs := strings.Split(shape[start+1:end], "/")
holed := false
for _, p := range places {
n, ok := pathPlaceIndex(p)
if ok && n < len(segs) && segs[n] != pathHole {
segs[n], holed = pathHole, true
}
}
if !holed {
return shape
}
return shape[:start+1] + strings.Join(segs, "/") + shape[end:]
}
// pathPlaceIndex is the segment a place "url.path[<n>]" names, read as a
// variable's place is read (a number from 0, without leading zeros), or a
// word of it ("url.path[<n>]#<w>", after any "|<step>": a word address, as
// variables.ParseAddress reads one); a place past url.path[9999] is ignored.
func pathPlaceIndex(place string) (int, bool) {
const prefix = "url.path["
if i := strings.IndexByte(place, ']'); i >= 0 && i+1 < len(place) {
if !isWordSuffix(place[i+1:]) {
return 0, false
}
place = place[:i+1]
}
if !strings.HasPrefix(place, prefix) || !strings.HasSuffix(place, "]") {
return 0, false
}
digits := place[len(prefix) : len(place)-1]
if digits == "" || len(digits) > 4 || (len(digits) > 1 && digits[0] == '0') {
return 0, false
}
n := 0
for i := 0; i < len(digits); i++ {
if !isDigitByte(digits[i]) {
return 0, false
}
n = n*10 + int(digits[i]-'0')
}
return n, true
}
// isWordSuffix reports whether s, what follows a place, is a word
// address's: steps ("|pct", "|json", "|b64", "|b64url", "|jwt.<claim>",
// "|cookie.<name>", a '|', '#' or '\' in a name escaped by '\'), then at
// most one word ("#<n>", a number from 0 without leading zeros), as
// variables.ParseAddress reads them.
func isWordSuffix(s string) bool {
for strings.HasPrefix(s, "|") {
i := 1
for ; i < len(s) && s[i] != '|' && s[i] != '#'; i++ {
if s[i] == '\\' {
i++
}
}
i = min(i, len(s))
// A name's escapes leave its step's kind and its being empty as
// they are: the raw name is read.
switch st := s[1:i]; {
case st == "pct" || st == "json" || st == "b64" || st == "b64url":
case len(st) > len("jwt.") && strings.HasPrefix(st, "jwt."):
case len(st) > len("cookie.") && strings.HasPrefix(st, "cookie."):
default:
return false
}
s = s[i:]
}
if s == "" {
return true
}
// A number prints as it is written; Atoi's 0 or bound on an error
// prints as no such text.
w, ok := strings.CutPrefix(s, "#")
n, _ := strconv.Atoi(w)
return ok && n >= 0 && strconv.Itoa(n) == w
}
func isASCIILetter(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
func isUpperByte(c byte) bool { return c >= 'A' && c <= 'Z' }
func isPathNameSeparator(c byte) bool {
return c == '.' || c == '-' || c == '_' || c == '~' || c == ':'
}
// maxSQLText bounds a statement's shape.
const maxSQLText = 160
// SQLShape is a SQL statement with every quoted string and number written
// "?". It fails closed: what it cannot read for certain is withheld, the rest
// of the statement written "…": a literal holding a backslash (MySQL reads
// \' as a quote by default, so where the literal ends depends on the
// server's sql_mode), one never closed, a dollar quote ($tag$), and a
// comment. MySQL's bound parameters ("params=[...]", which the replayer adds
// to a prepared statement's summary) are dropped whole, and a backtick
// identifier is kept only when it holds no quote. A quoted identifier
// (Postgres "table") is written "?" too: hiding a name beats keeping a value.
func SQLShape(q string) string {
if i := strings.Index(q, "params=["); i >= 0 {
q = q[:i]
}
var b strings.Builder
withheld := false
firstLiteral := -1
scan:
for i := 0; i < len(q); {
c := q[i]
switch {
case c == '\'' || c == '"':
if firstLiteral < 0 {
firstLiteral = b.Len()
}
end, ok := closeQuote(q, i)
if !ok {
// A literal never closed leaves no quote of the statement
// paired for certain: everything from the first is withheld.
if end < 0 {
s := b.String()[:firstLiteral]
b.Reset()
b.WriteString(s)
}
b.WriteString("?")
withheld = true
break scan
}
b.WriteString("?")
i = end
case c == '`':
// A MySQL identifier: kept, as a name. One never closed, or
// holding a quote (which a reader would take for a literal's
// start), is withheld with the rest; so is a backslash, and
// Postgres's operator character.
end, ok := closeQuote(q, i)
if !ok || strings.ContainsAny(q[i+1:end-1], `'"`) {
if !ok && end < 0 && firstLiteral >= 0 {
s := b.String()[:firstLiteral]
b.Reset()
b.WriteString(s)
}
withheld = true
break scan
}
// A name keeps its letters; a run of digits in it is written
// "?", in case the span was misread (a Postgres operator, a
// multi-byte character set's trail byte).
b.WriteString(digitRuns.ReplaceAllString(q[i:end], "?"))
i = end
case c == '$' && i+1 < len(q) && isDigitByte(q[i+1]):
// A placeholder ($1): kept.
j := i + 1
for j < len(q) && isDigitByte(q[j]) {
j++
}
b.WriteString(q[i:j])
i = j
case c == '$', c == '#', strings.HasPrefix(q[i:], "--"), strings.HasPrefix(q[i:], "/*"):
// A dollar quote or a comment: withheld.
withheld = true
break scan
case isDigitByte(c) && (i == 0 || !isIdentByte(q[i-1])):
j := i
for j < len(q) && (isIdentByte(q[j]) || q[j] == '.') {
j++
}
b.WriteString("?")
i = j
default:
b.WriteByte(c)
i++
}
}
out := strings.TrimSpace(sqlSpace.ReplaceAllString(b.String(), " "))
if withheld {
// A statement withheld from its first byte (a leading comment) is
// "…" alone, not " …".
out = strings.TrimSpace(out + " …")
}
if len(out) > maxSQLText {
i := maxSQLText
for i > 0 && !utf8.RuneStart(out[i]) {
i--
}
out = out[:i] + "…"
}
return out
}
// closeQuote is the index past the quote that closes the literal opening at
// q[i] (a doubled quote inside it is one quote), and false for a literal with
// a backslash in it (index 0), or one never closed (index -1).
func closeQuote(q string, i int) (int, bool) {
quote := q[i]
for j := i + 1; j < len(q); j++ {
switch q[j] {
case '\\':
return 0, false
case quote:
if j+1 < len(q) && q[j+1] == quote {
j++
continue
}
return j + 1, true
}
}
return -1, false
}
var (
sqlSpace = regexp.MustCompile(`\s+`)
digitRuns = regexp.MustCompile(`[0-9]+`)
)
func isDigitByte(c byte) bool { return c >= '0' && c <= '9' }
func isIdentByte(c byte) bool {
return c == '_' || isDigitByte(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
}