| index 86d3df454..1a5a7f39a 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -443,55 +443,118 @@ var dbReadPragmas = map[string]bool{ |
| // changes the connection (query_only, temp_store, temp_store_directory, |
| // writable_schema and the rest), since a pragma is the one statement that |
| // can loosen what follows it. |
| +// |
| +// The pragma rule is an invariant over the whole statement's tokens, not a |
| +// prefix check: SQLite skips byte-order marks, comments and whitespace |
| +// anywhere, and applies a pragma at prepare even under EXPLAIN. So wherever |
| +// a PRAGMA keyword appears, the pragma it names may be given a value only |
| +// if it is a read-only one, and only in the (argument) form. |
| func checkQuerySQL(q string) error { |
| - first, rest := splitFirstStatement(q) |
| - if strings.TrimSpace(stripSQLComments(rest)) != "" { |
| + _, rest := splitFirstStatement(q) |
| + if len(sqlTokens(rest)) > 0 { |
| return errOneStatement |
| } |
| - // SQLite skips a leading byte-order mark and treats space, tab, CR, LF |
| - // and form feed as whitespace, and it applies a pragma at prepare even |
| - // under EXPLAIN [QUERY PLAN]. Normalize all three away before looking. |
| - stmt := strings.ToLower(stripSQLComments(strings.TrimPrefix(first, "\uFEFF"))) |
| - stmt = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(stmt), ";")) |
| - stmt = cutSQLKeyword(stmt, "explain") |
| - if rest := cutSQLKeyword(stmt, "query"); rest != stmt { |
| - stmt = cutSQLKeyword(rest, "plan") |
| - } |
| - body := cutSQLKeyword(stmt, "pragma") |
| - if body == stmt { |
| - return nil |
| - } |
| - // PRAGMA [schema.]name [= value | (value)] |
| - n := 0 |
| - for n < len(body) && (isSQLIdentByte(body[n]) || body[n] == '.') { |
| - n++ |
| - } |
| - name := body[:n] |
| - if i := strings.LastIndexByte(name, '.'); i >= 0 { |
| - name = name[i+1:] |
| - } |
| - after := strings.TrimSpace(body[n:]) |
| - if after == "" { |
| - return nil // a bare read of the pragma's value |
| - } |
| - if dbReadPragmas[name] && strings.HasPrefix(after, "(") { |
| - return nil |
| + toks := sqlTokens(q) |
| + for i, tk := range toks { |
| + if tk.kind != sqlWord || tk.text != "pragma" { |
| + continue |
| + } |
| + // PRAGMA [schema .] name [= value | (value)] |
| + j := i + 1 |
| + name := "" |
| + if j < len(toks) && toks[j].kind != sqlPunct { |
| + name = toks[j].text |
| + j++ |
| + } |
| + if j+1 < len(toks) && toks[j].kind == sqlPunct && toks[j].text == "." && toks[j+1].kind != sqlPunct { |
| + name = toks[j+1].text |
| + j += 2 |
| + } |
| + if j >= len(toks) || toks[j].kind != sqlPunct || (toks[j].text != "=" && toks[j].text != "(") { |
| + continue // a bare read of the pragma's value |
| + } |
| + if toks[j].text == "(" && dbReadPragmas[name] { |
| + continue |
| + } |
| + return errPragmaChange |
| } |
| - return errPragmaChange |
| + return nil |
| +} |
| + |
| +type sqlTokenKind int |
| + |
| +const ( |
| + sqlWord sqlTokenKind = iota // keyword or bare identifier, lower case |
| + sqlQuoted // string literal or quoted identifier, unquoted, lower case |
| + sqlPunct // any other single byte |
| +) |
| + |
| +type sqlToken struct { |
| + kind sqlTokenKind |
| + text string |
| } |
| |
| -// cutSQLKeyword removes a leading keyword (already lower case) and the |
| -// whitespace after it, or returns s unchanged when it does not start with |
| -// that whole word. |
| -func cutSQLKeyword(s, kw string) string { |
| - if !strings.HasPrefix(s, kw) || (len(s) > len(kw) && isSQLIdentByte(s[len(kw)])) { |
| - return s |
| +const utf8BOM = "\uFEFF" |
| + |
| +// sqlTokens splits q the way SQLite's tokenizer does, for the guard's |
| +// purposes: comments and whitespace (space, tab, newline, carriage return, |
| +// form feed, vertical tab, and a byte-order mark anywhere) separate tokens |
| +// and vanish; string literals and quoted identifiers ('...', "...", `...`, |
| +// [...]) are one token each; runs of identifier bytes are words; anything |
| +// else is one punctuation byte. |
| +func sqlTokens(q string) []sqlToken { |
| + var out []sqlToken |
| + for i := 0; i < len(q); { |
| + c := q[i] |
| + switch { |
| + case strings.HasPrefix(q[i:], utf8BOM): |
| + i += len(utf8BOM) |
| + case c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v': |
| + i++ |
| + case c == '-' && i+1 < len(q) && q[i+1] == '-': |
| + if j := strings.IndexByte(q[i:], '\n'); j >= 0 { |
| + i += j + 1 |
| + } else { |
| + i = len(q) |
| + } |
| + case c == '/' && i+1 < len(q) && q[i+1] == '*': |
| + if j := strings.Index(q[i+2:], "*/"); j >= 0 { |
| + i += j + 4 |
| + } else { |
| + i = len(q) |
| + } |
| + case c == '\'' || c == '"' || c == '`': |
| + end := min(skipQuoted(q, i, c), len(q)) |
| + body := strings.ReplaceAll(q[i+1:end], string([]byte{c, c}), string(c)) |
| + out = append(out, sqlToken{sqlQuoted, strings.ToLower(body)}) |
| + i = end + 1 |
| + case c == '[': |
| + j := strings.IndexByte(q[i:], ']') |
| + if j < 0 { |
| + j = len(q) - i |
| + } |
| + out = append(out, sqlToken{sqlQuoted, strings.ToLower(q[i+1 : i+j])}) |
| + i += j + 1 |
| + case isSQLIdentByte(c): |
| + j := i |
| + for j < len(q) && isSQLIdentByte(q[j]) && !strings.HasPrefix(q[j:], utf8BOM) { |
| + j++ |
| + } |
| + out = append(out, sqlToken{sqlWord, strings.ToLower(q[i:j])}) |
| + i = j |
| + default: |
| + out = append(out, sqlToken{sqlPunct, string(c)}) |
| + i++ |
| + } |
| } |
| - return strings.TrimSpace(s[len(kw):]) |
| + return out |
| } |
| |
| +// isSQLIdentByte reports a byte that can continue an unquoted identifier: |
| +// ASCII letters, digits, _ and $, and any byte of a multi-byte UTF-8 |
| +// character (the byte-order mark is checked for before this). |
| func isSQLIdentByte(c byte) bool { |
| - return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' |
| + return c == '_' || c == '$' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 |
| } |
| |
| // splitFirstStatement returns the text up to and including the first |
| @@ -545,38 +608,3 @@ func skipQuoted(q string, i int, quote byte) int { |
| } |
| return len(q) |
| } |
| - |
| -// stripSQLComments removes -- and /* */ comments outside quotes, leaving a |
| -// space in their place. |
| -func stripSQLComments(q string) string { |
| - var b strings.Builder |
| - for i := 0; i < len(q); i++ { |
| - c := q[i] |
| - switch { |
| - case c == '\'' || c == '"' || c == '`': |
| - j := skipQuoted(q, i, c) |
| - if j >= len(q) { |
| - j = len(q) - 1 |
| - } |
| - b.WriteString(q[i : j+1]) |
| - i = j |
| - case c == '-' && i+1 < len(q) && q[i+1] == '-': |
| - j := strings.IndexByte(q[i:], '\n') |
| - if j < 0 { |
| - return b.String() |
| - } |
| - b.WriteByte(' ') |
| - i += j |
| - case c == '/' && i+1 < len(q) && q[i+1] == '*': |
| - j := strings.Index(q[i+2:], "*/") |
| - if j < 0 { |
| - return b.String() |
| - } |
| - b.WriteByte(' ') |
| - i += j + 3 |
| - default: |
| - b.WriteByte(c) |
| - } |
| - } |
| - return b.String() |
| -} |