Files
MyGoNavi/internal/sqlaudit/redact.go
Syngnat 7c8e8d8dd3 feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
2026-07-12 15:24:27 +08:00

801 lines
22 KiB
Go

package sqlaudit
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
var sqlQueryTypes = map[string]struct{}{
"mysql": {}, "mariadb": {}, "goldendb": {}, "greatdb": {}, "gdb": {},
"oceanbase": {}, "doris": {}, "diros": {}, "starrocks": {}, "sphinx": {},
"postgres": {}, "postgresql": {}, "sqlserver": {}, "mssql": {}, "sqlite": {},
"duckdb": {}, "oracle": {}, "dameng": {}, "dm": {}, "kingbase": {}, "highgo": {},
"vastbase": {}, "opengauss": {}, "gaussdb": {}, "iris": {}, "intersystems": {},
"tdengine": {}, "iotdb": {}, "clickhouse": {}, "trino": {}, "custom": {},
"gonavi": {},
}
var redisReadCommands = map[string]struct{}{
"GET": {}, "MGET": {}, "GETDEL": {}, "GETEX": {}, "STRLEN": {}, "TYPE": {},
"TTL": {}, "PTTL": {}, "EXPIRETIME": {}, "PEXPIRETIME": {}, "EXISTS": {},
"DEL": {}, "UNLINK": {}, "TOUCH": {}, "DUMP": {}, "OBJECT": {}, "KEYS": {},
"SCAN": {}, "HGET": {}, "HMGET": {}, "HGETALL": {}, "HEXISTS": {}, "HLEN": {},
"HKEYS": {}, "HVALS": {}, "HSCAN": {}, "LINDEX": {}, "LLEN": {}, "LRANGE": {},
"LPOS": {}, "SCARD": {}, "SMEMBERS": {}, "SISMEMBER": {}, "SMISMEMBER": {},
"SSCAN": {}, "ZCARD": {}, "ZCOUNT": {}, "ZLEXCOUNT": {}, "ZRANGE": {},
"ZRANGEBYSCORE": {}, "ZRANK": {}, "ZREVRANK": {}, "ZSCORE": {}, "ZMSCORE": {},
"ZSCAN": {}, "XRANGE": {}, "XREVRANGE": {}, "XLEN": {}, "XREAD": {}, "XINFO": {},
"PFCOUNT": {}, "BITCOUNT": {}, "GETBIT": {}, "GEODIST": {}, "GEOHASH": {},
"GEOPOS": {}, "GEOSEARCH": {}, "JSON.GET": {}, "JSON.MGET": {}, "JSON.TYPE": {},
"JSON.OBJKEYS": {}, "JSON.OBJLEN": {}, "JSON.ARRLEN": {}, "TS.GET": {},
"TS.RANGE": {}, "TS.REVRANGE": {}, "TS.MGET": {}, "TS.MRANGE": {},
}
var redisMultiKeyCommands = map[string]struct{}{
"MGET": {}, "DEL": {}, "UNLINK": {}, "EXISTS": {}, "TOUCH": {},
}
// RedactQuery dispatches query sanitization by data-source type. SQL dialects
// use the SQL lexer, Redis retains only commands/keys and replaces values, and
// known non-SQL/message or unknown query languages default to metadata-only.
func RedactQuery(dbType, text string) string {
normalizedType := normalizeQueryType(dbType)
if normalizedType == "redis" {
return redactRedisQuery(text)
}
if _, ok := sqlQueryTypes[normalizedType]; ok {
return RedactSQL(text)
}
return ""
}
const (
maxSQLRunes = 64 * 1024
maxErrorRunes = 4 * 1024
)
var (
sensitiveAssignmentPattern = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)\b(\s*(?:=|:)\s*)([^\s,;]+)`)
identifiedByPattern = regexp.MustCompile(`(?i)\b(identified\s+by)(\s+)([^\s,;]+)`)
uriUserInfoPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.-]*://)([^\s/@:]+)(?::[^\s/@]*)?@`)
bareUserInfoPattern = regexp.MustCompile(`(?i)\b[^\s:@/]+:[^\s@/]+@(\[[^\]\s]+\]|[a-z0-9.-]+)`)
slashUserInfoPattern = regexp.MustCompile(`(?i)\b[^\s/@:]+/[^\s@/]+@([a-z0-9.-]+)(?:/[^\s,;]*)?`)
bearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[a-z0-9._~+/=-]+`)
basicAuthPattern = regexp.MustCompile(`(?i)\bbasic\s+[a-z0-9+/=]+`)
postgresKeyDetailPattern = regexp.MustCompile(`(?i)(\bkey\s*\([^\)\r\n]*\)\s*=\s*)\([^\)\r\n]*\)`)
duplicateKeyValuePattern = regexp.MustCompile(`(?i)(\bduplicate\s+key\s+value\s+(?:is|was)\s*)\([^\)\r\n]*\)`)
failingRowPattern = regexp.MustCompile(`(?i)(\bfailing\s+row\s+contains\s*)\([^\)\r\n]*\)`)
)
// RedactSQL removes comments and literal values while retaining enough SQL
// structure for local diagnostics. It intentionally favors confidentiality
// over perfectly preserving dialect-specific quoted identifiers.
func RedactSQL(sqlText string) string {
if strings.TrimSpace(sqlText) == "" {
return ""
}
var out strings.Builder
out.Grow(len(sqlText))
for index := 0; index < len(sqlText); {
if hasPrefixAt(sqlText, index, "--") || sqlText[index] == '#' {
index = skipLineComment(sqlText, index)
if index < len(sqlText) && (sqlText[index] == '\r' || sqlText[index] == '\n') {
out.WriteByte('\n')
}
continue
}
if hasPrefixAt(sqlText, index, "/*") {
index = skipBlockComment(sqlText, index+2)
out.WriteByte(' ')
continue
}
if next, ok := skipOracleQuotedLiteral(sqlText, index); ok {
out.WriteByte('?')
index = next
continue
}
if sqlText[index] == '$' {
if next, ok := skipDollarQuotedLiteral(sqlText, index); ok {
out.WriteByte('?')
index = next
continue
}
}
if sqlText[index] == '\'' || sqlText[index] == '"' {
quote := sqlText[index]
out.WriteByte(quote)
out.WriteByte('?')
out.WriteByte(quote)
index = skipQuoted(sqlText, index, quote)
continue
}
if isNumberStart(sqlText, index) {
out.WriteByte('?')
index = skipNumber(sqlText, index)
continue
}
r, size := utf8.DecodeRuneInString(sqlText[index:])
if r == utf8.RuneError && size == 1 {
out.WriteRune(unicode.ReplacementChar)
index++
continue
}
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
out.WriteByte(' ')
} else {
out.WriteRune(r)
}
index += size
}
redacted := sensitiveAssignmentPattern.ReplaceAllString(out.String(), `${1}${2}?`)
redacted = identifiedByPattern.ReplaceAllString(redacted, `${1}${2}?`)
redacted = uriUserInfoPattern.ReplaceAllString(redacted, `${1}***:***@`)
redacted = bearerPattern.ReplaceAllString(redacted, "Bearer ***")
return truncateRunes(strings.TrimSpace(redacted), maxSQLRunes)
}
// RedactError removes common credential forms and quoted values from a driver
// error before it enters the audit database.
func RedactError(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return ""
}
message = uriUserInfoPattern.ReplaceAllString(message, `${1}***:***@`)
message = bareUserInfoPattern.ReplaceAllString(message, `***:***@$1`)
message = slashUserInfoPattern.ReplaceAllString(message, `***/***@$1`)
message = bearerPattern.ReplaceAllString(message, "Bearer ***")
message = basicAuthPattern.ReplaceAllString(message, "Basic ***")
message = postgresKeyDetailPattern.ReplaceAllString(message, `${1}(?)`)
message = duplicateKeyValuePattern.ReplaceAllString(message, `${1}(?)`)
message = failingRowPattern.ReplaceAllString(message, `${1}(?)`)
message = sensitiveAssignmentPattern.ReplaceAllString(message, `${1}${2}***`)
message = identifiedByPattern.ReplaceAllString(message, `${1}${2}***`)
message = redactQuotedSegments(message)
message = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, message)
return truncateRunes(strings.TrimSpace(message), maxErrorRunes)
}
func normalizeQueryType(dbType string) string {
normalized := strings.ToLower(strings.TrimSpace(dbType))
switch normalized {
case "redis-cluster", "redis_cluster", "redis-sentinel", "redis_sentinel":
return "redis"
case "rocket-mq", "rocket_mq", "apache-rocketmq", "apache_rocketmq", "rmq":
return "rocketmq"
case "apache-kafka", "apache_kafka":
return "kafka"
case "rabbit-mq", "rabbit_mq":
return "rabbitmq"
case "elastic":
return "elasticsearch"
case "chromadb", "chroma-db":
return "chroma"
case "qdrantdb", "qdrant-db":
return "qdrant"
case "milvusdb", "milvus-db":
return "milvus"
default:
return normalized
}
}
func redactRedisQuery(text string) string {
tokens, ok := tokenizeRedisQuery(text)
if !ok || len(tokens) == 0 {
return ""
}
command := strings.ToUpper(tokens[0])
switch command {
case "AUTH":
if len(tokens) >= 3 {
return "AUTH ? ?"
}
if len(tokens) == 2 {
return "AUTH ?"
}
return "AUTH"
case "HELLO":
return redactRedisHello(tokens)
case "CONFIG":
return redactRedisConfig(tokens)
case "ACL":
return redactRedisACL(tokens)
case "CLIENT":
return redactRedisClient(tokens)
case "SCRIPT":
return redactRedisScript(tokens)
case "FUNCTION":
return redactRedisFunction(tokens)
case "PING", "ECHO":
if len(tokens) > 1 {
return command + " ?"
}
return command
case "MSET", "MSETNX":
return redactRedisAlternatingPairs(command, tokens, 1)
case "HSET", "HMSET":
return redactRedisHashPairs(command, tokens)
case "SET", "SETNX", "GETSET", "APPEND", "SETRANGE", "INCRBY", "INCRBYFLOAT",
"DECRBY", "SETBIT", "SETEX", "PSETEX", "RESTORE", "LSET", "LINSERT",
"HSETNX", "HINCRBY", "HINCRBYFLOAT", "LPUSH", "LPUSHX", "RPUSH", "RPUSHX",
"LREM", "SADD", "SREM", "SMOVE",
"ZADD", "ZINCRBY", "ZREM", "GEOADD", "PFADD", "PUBLISH", "SPUBLISH",
"XADD", "XACK", "XCLAIM", "XAUTOCLAIM", "XDEL", "XGROUP", "XTRIM",
"JSON.SET", "JSON.MERGE", "JSON.ARRAPPEND", "JSON.ARRINSERT", "JSON.ARRTRIM",
"JSON.NUMINCRBY", "JSON.NUMMULTBY", "JSON.STRAPPEND", "TS.ADD", "TS.INCRBY",
"TS.DECRBY":
return redactRedisKeyAndPayload(command, tokens)
case "EVAL", "EVALSHA", "FCALL", "FCALL_RO":
return redactRedisScriptInvocation(command, tokens)
case "MIGRATE", "MODULE":
return command + " ?"
case "SELECT", "SWAPDB":
return command + redactRedisSafeArguments(tokens[1:])
default:
if _, ok := redisReadCommands[command]; !ok {
return ""
}
if len(tokens) == 1 {
return command
}
if _, ok := redisMultiKeyCommands[command]; ok {
return command + redactRedisSafeArguments(tokens[1:])
}
// Retain the first key/pattern only. Later arguments can be members,
// payloads or dialect-specific options and are omitted conservatively.
return command + " " + formatRedisToken(tokens[1])
}
}
func redactRedisHello(tokens []string) string {
parts := []string{"HELLO"}
index := 1
if index < len(tokens) {
if _, err := strconv.Atoi(tokens[index]); err == nil {
parts = append(parts, tokens[index])
} else {
parts = append(parts, "?")
}
index++
}
for index < len(tokens) {
switch strings.ToUpper(tokens[index]) {
case "AUTH":
parts = append(parts, "AUTH", "?", "?")
index += 3
case "SETNAME":
parts = append(parts, "SETNAME", "?")
index += 2
default:
// Unknown HELLO options may carry data; retain no further text.
index = len(tokens)
}
}
return strings.Join(parts, " ")
}
func redactRedisConfig(tokens []string) string {
if len(tokens) < 2 {
return "CONFIG"
}
subcommand := strings.ToUpper(tokens[1])
switch subcommand {
case "SET":
if len(tokens) >= 3 {
return "CONFIG SET " + formatRedisToken(tokens[2]) + " ?"
}
return "CONFIG SET ?"
case "GET":
if len(tokens) >= 3 {
return "CONFIG GET " + formatRedisToken(tokens[2])
}
return "CONFIG GET"
case "RESETSTAT", "REWRITE":
return "CONFIG " + subcommand
default:
return "CONFIG"
}
}
func redactRedisACL(tokens []string) string {
if len(tokens) < 2 {
return "ACL"
}
subcommand := strings.ToUpper(tokens[1])
switch subcommand {
case "SETUSER":
if len(tokens) >= 3 {
return "ACL SETUSER " + formatRedisToken(tokens[2]) + " ?"
}
return "ACL SETUSER ?"
case "GETUSER", "DELUSER":
if len(tokens) >= 3 {
return "ACL " + subcommand + " " + formatRedisToken(tokens[2])
}
return "ACL " + subcommand
case "LIST", "USERS", "WHOAMI", "CAT", "GENPASS", "LOG", "SAVE":
return "ACL " + subcommand
default:
return "ACL"
}
}
func redactRedisClient(tokens []string) string {
if len(tokens) < 2 {
return "CLIENT"
}
subcommand := strings.ToUpper(tokens[1])
switch subcommand {
case "SETNAME", "SETINFO", "KILL", "UNBLOCK":
return "CLIENT " + subcommand + " ?"
case "GETNAME", "ID", "INFO", "LIST", "PAUSE", "UNPAUSE", "REPLY", "TRACKING", "CACHING":
return "CLIENT " + subcommand
default:
return "CLIENT"
}
}
func redactRedisScript(tokens []string) string {
if len(tokens) < 2 {
return "SCRIPT"
}
subcommand := strings.ToUpper(tokens[1])
switch subcommand {
case "LOAD", "DEBUG":
return "SCRIPT " + subcommand + " ?"
case "EXISTS", "FLUSH", "KILL", "HELP":
return "SCRIPT " + subcommand
default:
return "SCRIPT"
}
}
func redactRedisFunction(tokens []string) string {
if len(tokens) < 2 {
return "FUNCTION"
}
subcommand := strings.ToUpper(tokens[1])
switch subcommand {
case "LOAD", "RESTORE":
return "FUNCTION " + subcommand + " ?"
case "DELETE":
if len(tokens) >= 3 {
return "FUNCTION DELETE " + formatRedisToken(tokens[2])
}
return "FUNCTION DELETE"
case "DUMP", "FLUSH", "KILL", "LIST", "STATS", "HELP":
return "FUNCTION " + subcommand
default:
return "FUNCTION"
}
}
func redactRedisAlternatingPairs(command string, tokens []string, start int) string {
if len(tokens) <= start || (len(tokens)-start)%2 != 0 {
return command + " ?"
}
parts := []string{command}
for index := start; index < len(tokens); index += 2 {
parts = append(parts, formatRedisToken(tokens[index]))
if index+1 < len(tokens) {
parts = append(parts, "?")
}
}
return strings.Join(parts, " ")
}
func redactRedisHashPairs(command string, tokens []string) string {
if len(tokens) < 2 {
return command
}
if (len(tokens)-2)%2 != 0 {
return command + " " + formatRedisToken(tokens[1]) + " ?"
}
parts := []string{command, formatRedisToken(tokens[1])}
for index := 2; index < len(tokens); index += 2 {
parts = append(parts, formatRedisToken(tokens[index]))
if index+1 < len(tokens) {
parts = append(parts, "?")
}
}
return strings.Join(parts, " ")
}
func redactRedisKeyAndPayload(command string, tokens []string) string {
if len(tokens) < 2 {
return command
}
result := command + " " + formatRedisToken(tokens[1])
if len(tokens) > 2 {
result += " ?"
}
return result
}
func redactRedisScriptInvocation(command string, tokens []string) string {
if len(tokens) < 3 {
return command + " ?"
}
numKeys, err := strconv.Atoi(tokens[2])
if err != nil || numKeys < 0 {
return command + " ?"
}
parts := []string{command, "?", strconv.Itoa(numKeys)}
for index := 0; index < numKeys && 3+index < len(tokens); index++ {
parts = append(parts, formatRedisToken(tokens[3+index]))
}
if 3+numKeys < len(tokens) {
parts = append(parts, "?")
}
return strings.Join(parts, " ")
}
func redactRedisSafeArguments(tokens []string) string {
if len(tokens) == 0 {
return ""
}
parts := make([]string, 0, len(tokens))
for _, token := range tokens {
parts = append(parts, formatRedisToken(token))
}
return " " + strings.Join(parts, " ")
}
func tokenizeRedisQuery(text string) ([]string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return nil, true
}
var tokens []string
var current strings.Builder
var quote rune
escaped := false
tokenStarted := false
flush := func() {
if !tokenStarted {
return
}
tokens = append(tokens, current.String())
current.Reset()
tokenStarted = false
}
for _, r := range text {
if escaped {
current.WriteRune(r)
escaped = false
tokenStarted = true
continue
}
if r == '\\' {
escaped = true
tokenStarted = true
continue
}
if quote != 0 {
if r == quote {
quote = 0
} else {
current.WriteRune(r)
}
tokenStarted = true
continue
}
if r == '\'' || r == '"' {
quote = r
tokenStarted = true
continue
}
if unicode.IsSpace(r) {
flush()
continue
}
current.WriteRune(r)
tokenStarted = true
}
if escaped {
current.WriteRune('\\')
}
if quote != 0 {
return nil, false
}
flush()
return tokens, true
}
func formatRedisToken(token string) string {
token = truncateRunes(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, token), 512)
if token == "" {
return `""`
}
if strings.IndexFunc(token, unicode.IsSpace) >= 0 {
return strconv.Quote(token)
}
return token
}
func queryFingerprintSeed(dbType, redactedText string) string {
if redactedText != "" {
return redactedText
}
// Metadata-only query languages intentionally do not include raw input in
// their fingerprint. Payload-derived hashes can be dictionary-guessed when
// messages contain low-entropy tokens, card numbers or common credentials.
return "metadata:" + normalizeQueryType(dbType)
}
func sanitizeEvent(event Event, settings Settings) Event {
event.Sequence = 0
event.PrevHash = ""
event.Hash = ""
event.EventType = strings.ToLower(sanitizeLabel(event.EventType, 64))
event.Status = strings.ToLower(sanitizeLabel(event.Status, 64))
event.ConnectionID = sanitizeLabel(event.ConnectionID, 256)
event.DBType = strings.ToLower(sanitizeLabel(event.DBType, 64))
event.Database = sanitizeLabel(event.Database, 512)
event.QueryID = sanitizeLabel(event.QueryID, 256)
event.TransactionID = sanitizeLabel(event.TransactionID, 256)
event.Source = strings.ToLower(sanitizeLabel(event.Source, 64))
event.BoundaryMode = normalizeBoundaryMode(event.BoundaryMode)
event.CommitMode = normalizeCommitMode(event.CommitMode)
rawSQL := event.SQLText
redactedSQL := RedactQuery(event.DBType, rawSQL)
fingerprintSeed := queryFingerprintSeed(event.DBType, redactedSQL)
event.SQLFingerprint = versionedDigest("sqlaudit-sql-fingerprint-v1", fingerprintSeed)
event.SQLRedacted = true
if settings.CaptureMode == CaptureModeMetadata {
event.SQLText = ""
} else {
event.SQLText = redactedSQL
}
fingerprintSeed = strings.TrimSpace(event.ConnectionFingerprint)
if fingerprintSeed == "" {
fingerprintSeed = strings.Join([]string{event.ConnectionID, event.DBType, event.Database}, "\x00")
}
event.ConnectionFingerprint = versionedDigest("sqlaudit-connection-fingerprint-v1", fingerprintSeed)
event.Error = RedactError(event.Error)
if event.StatementIndex < 0 {
event.StatementIndex = 0
}
if event.StatementCount < 0 {
event.StatementCount = 0
}
if event.DurationMs < 0 {
event.DurationMs = 0
}
if event.RowsAffected < 0 {
event.RowsAffected = 0
}
if event.RowsReturned < 0 {
event.RowsReturned = 0
}
return event
}
func normalizeBoundaryMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case BoundaryModeDriverAPI:
return BoundaryModeDriverAPI
case BoundaryModeTextSQL:
return BoundaryModeTextSQL
case BoundaryModeImplicit:
return BoundaryModeImplicit
default:
return BoundaryModeUnknown
}
}
func normalizeCommitMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case CommitModeAuto:
return CommitModeAuto
case CommitModeManual:
return CommitModeManual
case CommitModePending:
return CommitModePending
default:
return ""
}
}
func versionedDigest(version, value string) string {
digest := sha256.Sum256([]byte(version + "\x00" + value))
return hex.EncodeToString(digest[:])
}
func sanitizeLabel(value string, maxRunes int) string {
value = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, strings.TrimSpace(value))
return truncateRunes(value, maxRunes)
}
func truncateRunes(value string, max int) string {
if max <= 0 || utf8.RuneCountInString(value) <= max {
return value
}
runes := []rune(value)
return string(runes[:max])
}
func redactQuotedSegments(value string) string {
var out strings.Builder
out.Grow(len(value))
for index := 0; index < len(value); {
if value[index] == '\'' || value[index] == '"' {
quote := value[index]
out.WriteByte(quote)
out.WriteByte('?')
out.WriteByte(quote)
index = skipQuoted(value, index, quote)
continue
}
out.WriteByte(value[index])
index++
}
return out.String()
}
func skipQuoted(value string, start int, quote byte) int {
for index := start + 1; index < len(value); index++ {
if value[index] == '\\' {
index++
continue
}
if value[index] != quote {
continue
}
if index+1 < len(value) && value[index+1] == quote {
index++
continue
}
return index + 1
}
return len(value)
}
func skipLineComment(value string, start int) int {
for index := start; index < len(value); index++ {
if value[index] == '\r' || value[index] == '\n' {
return index
}
}
return len(value)
}
func skipBlockComment(value string, start int) int {
depth := 1
for index := start; index+1 < len(value); {
switch value[index : index+2] {
case "/*":
depth++
index += 2
case "*/":
depth--
index += 2
if depth == 0 {
return index
}
default:
index++
}
}
return len(value)
}
func skipDollarQuotedLiteral(value string, start int) (int, bool) {
endTag := start + 1
for endTag < len(value) && (value[endTag] == '_' || isASCIILetterOrDigit(value[endTag])) {
endTag++
}
if endTag >= len(value) || value[endTag] != '$' {
return start, false
}
tag := value[start : endTag+1]
contentEnd := strings.Index(value[endTag+1:], tag)
if contentEnd < 0 {
return len(value), true
}
return endTag + 1 + contentEnd + len(tag), true
}
func skipOracleQuotedLiteral(value string, start int) (int, bool) {
if start+3 >= len(value) || (value[start] != 'q' && value[start] != 'Q') || value[start+1] != '\'' {
return start, false
}
opening := value[start+2]
closing := opening
switch opening {
case '[':
closing = ']'
case '{':
closing = '}'
case '(':
closing = ')'
case '<':
closing = '>'
}
for index := start + 3; index+1 < len(value); index++ {
if value[index] == closing && value[index+1] == '\'' {
return index + 2, true
}
}
return len(value), true
}
func isNumberStart(value string, index int) bool {
if index >= len(value) || value[index] < '0' || value[index] > '9' {
return false
}
if index == 0 {
return true
}
previous := value[index-1]
return !(previous == '_' || previous == '$' || isASCIILetterOrDigit(previous))
}
func skipNumber(value string, start int) int {
index := start
if index+2 <= len(value) && index+1 < len(value) && value[index] == '0' && (value[index+1] == 'x' || value[index+1] == 'X') {
index += 2
for index < len(value) && isASCIIHex(value[index]) {
index++
}
return index
}
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
index++
}
if index < len(value) && value[index] == '.' {
index++
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
index++
}
}
if index < len(value) && (value[index] == 'e' || value[index] == 'E') {
index++
if index < len(value) && (value[index] == '+' || value[index] == '-') {
index++
}
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
index++
}
}
return index
}
func hasPrefixAt(value string, index int, prefix string) bool {
return index >= 0 && index+len(prefix) <= len(value) && value[index:index+len(prefix)] == prefix
}
func isASCIILetterOrDigit(value byte) bool {
return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
}
func isASCIIHex(value byte) bool {
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
}