diff --git a/internal/impl/mysql/validate.go b/internal/impl/mysql/validate.go index 6c4ae184e8..e52c09bb40 100644 --- a/internal/impl/mysql/validate.go +++ b/internal/impl/mysql/validate.go @@ -10,7 +10,6 @@ package mysql import ( "errors" - "regexp" "unicode/utf8" ) @@ -21,25 +20,74 @@ var ( errInvalidTableName = errors.New("invalid table name") ) +// isExtendedIdentifierRune reports whether r falls in the extended range MySQL +// permits in unquoted identifiers, U+0080 to U+FFFF. Supplementary characters +// (U+10000 and above) are not permitted. +// +// See https://dev.mysql.com/doc/refman/8.4/en/identifiers.html +func isExtendedIdentifierRune(r rune) bool { + return r >= 0x80 && r <= 0xFFFF +} + +// isIdentifierStartRune reports whether r may begin an unquoted table name. +func isIdentifierStartRune(r rune) bool { + switch { + case r == '_': + return true + case 'a' <= r && r <= 'z': + return true + case 'A' <= r && r <= 'Z': + return true + default: + return isExtendedIdentifierRune(r) + } +} + +// isIdentifierRune reports whether r may appear after the first character of an +// unquoted table name. +func isIdentifierRune(r rune) bool { + switch { + case '0' <= r && r <= '9': + return true + case r == '$': + return true + default: + return isIdentifierStartRune(r) + } +} + func validateTableName(tableName string) error { // Check if empty if tableName == "" { return errEmptyTableName } + // Reject malformed input up front. Ranging over a string yields + // utf8.RuneError for an invalid byte, which sits inside the extended range + // and would otherwise be accepted. + if !utf8.ValidString(tableName) { + return errInvalidTableName + } + // Check length if utf8.RuneCountInString(tableName) > 64 { return errInvalidTableLength } - // Check if starts with a valid character - if matched, _ := regexp.MatchString(`^[a-zA-Z_]`, tableName); !matched { - return errInvalidTableStartChar - } + for i, r := range tableName { + // i is a byte offset, so it is only zero for the first rune. + if i == 0 { + // Check if starts with a valid character + if !isIdentifierStartRune(r) { + return errInvalidTableStartChar + } + continue + } - // Check if contains only valid characters - if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_$]+$`, tableName); !matched { - return errInvalidTableName + // Check if contains only valid characters + if !isIdentifierRune(r) { + return errInvalidTableName + } } return nil diff --git a/internal/impl/mysql/validate_test.go b/internal/impl/mysql/validate_test.go index 0dd304ce18..9beee94512 100644 --- a/internal/impl/mysql/validate_test.go +++ b/internal/impl/mysql/validate_test.go @@ -45,6 +45,29 @@ func TestValidateTableName(t *testing.T) { tableName: "UserProfiles", expectedErr: nil, }, + // MySQL permits the extended range U+0080..U+FFFF in unquoted + // identifiers, alongside the ASCII set. + // https://dev.mysql.com/doc/refman/8.4/en/identifiers.html + { + name: "Valid table name with accented latin characters", + tableName: "café", + expectedErr: nil, + }, + { + name: "Valid table name starting with an extended character", + tableName: "日本語テーブル", + expectedErr: nil, + }, + { + name: "Valid table name with cyrillic characters", + tableName: "Пользователи", + expectedErr: nil, + }, + { + name: "Valid table name mixing ascii and extended characters", + tableName: "orders_ñ_2024", + expectedErr: nil, + }, // Invalid cases { @@ -77,6 +100,21 @@ func TestValidateTableName(t *testing.T) { tableName: strings.Repeat("a", 65), expectedErr: errInvalidTableLength, }, + { + name: "Too long table name counted in runes", + tableName: strings.Repeat("é", 65), + expectedErr: errInvalidTableLength, + }, + { + name: "Supplementary characters are not permitted", + tableName: "table_😀", + expectedErr: errInvalidTableName, + }, + { + name: "Invalid utf-8 is rejected", + tableName: "table_" + string([]byte{0xff}), + expectedErr: errInvalidTableName, + }, } for _, tc := range tests {